Skip to content

Commit c421ca7

Browse files
committed
feat(oauth): use oauth to unify service-to-service authentication.
1 parent deb35a2 commit c421ca7

9 files changed

Lines changed: 355 additions & 40 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@
22
*.swp
33
*.swo
44
.DS_Store
5+
.sisyphus
6+
__pycache__
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
apiVersion: batch/v1
2+
kind: CronJob
3+
metadata:
4+
name: grafana-oauth2-token-refresher
5+
labels:
6+
app: grafana
7+
component: token-refresher
8+
heritage: drycc
9+
spec:
10+
# Run daily at 2 AM (avoid peak business hours)
11+
schedule: "0 2 * * *"
12+
timeZone: "Asia/Shanghai"
13+
14+
# Prevent concurrent executions (ensure a single instance)
15+
concurrencyPolicy: Forbid
16+
17+
# Keep the latest 3 successful and failed job records
18+
successfulJobsHistoryLimit: 3
19+
failedJobsHistoryLimit: 3
20+
21+
jobTemplate:
22+
spec:
23+
# Retry the job up to 3 times
24+
backoffLimit: 3
25+
26+
template:
27+
metadata:
28+
labels:
29+
app: grafana
30+
component: token-refresher
31+
spec:
32+
restartPolicy: OnFailure
33+
containers:
34+
- name: token-refresher
35+
image: {{ .Values.imageRegistry }}/{{ .Values.imageOrg }}/grafana:{{ .Values.imageTag }}
36+
imagePullPolicy: {{ .Values.imagePullPolicy }}
37+
command: ["/usr/bin/env", "python3", "/usr/share/grafana/oauth2/token.py"]
38+
{{- include "grafana.envs" . | indent 12 }}
39+
resources:
40+
requests:
41+
memory: "64Mi"
42+
cpu: "100m"
43+
limits:
44+
memory: "128Mi"
45+
cpu: "200m"

rootfs/usr/share/grafana/oauth2/datasources/prometheus.json

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,12 @@
22
"name": "Prometheus on Drycc",
33
"type": "prometheus",
44
"uid": "prometheus_on_drycc",
5-
"url": "${controller_url}/v2/prometheus/${workspace}",
5+
"url": "http://localhost:4000/proxy/prometheus/${workspace}",
66
"access": "proxy",
77
"isDefault": true,
88
"basicAuth": false,
99
"jsonData": {
10-
"httpHeaderName1": "Authorization",
1110
"httpMethod": "POST",
1211
"timeInterval": "${time_interval}"
13-
},
14-
"secureJsonData": {
15-
"httpHeaderValue1": "Token ${token}"
1612
}
17-
}
13+
}

rootfs/usr/share/grafana/oauth2/datasources/quickwit.json

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,11 @@
22
"name": "Application Logs",
33
"type": "quickwit-quickwit-datasource",
44
"uid": "application_logs",
5-
"url": "${controller_url}/v2/quickwit/${workspace}",
5+
"url": "http://localhost:4000/proxy/quickwit/${workspace}",
66
"access": "proxy",
77
"basicAuth": false,
88
"jsonData": {
9-
"httpHeaderName1": "Authorization",
109
"index": "logs-*",
1110
"logMessageField": "log"
12-
},
13-
"secureJsonData": {
14-
"httpHeaderValue1": "Token ${token}"
1511
}
1612
}

rootfs/usr/share/grafana/oauth2/hook/grafana.py

Lines changed: 23 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
import logging
2-
import os
32
import time
43
import json
54
import httpx
5+
from pathlib import Path
66
from string import Template
77
from psycopg import AsyncConnection
8+
from ..settings import settings
89

910
logger = logging.getLogger(__name__)
1011

1112
DEFAULT_HEADERS = {"Content-Type": "application/json"}
12-
DRYCC_CONTROLLER_URL = os.environ.get('DRYCC_CONTROLLER_URL')
13-
DRYCC_GRAFANA_REFRESH = os.environ.get('DRYCC_GRAFANA_REFRESH', '60s')
14-
DRYCC_GRAFANA_DASHBOARD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../")
13+
DRYCC_GRAFANA_DASHBOARD = Path(__file__).resolve().parent.parent
1514

1615
# Drycc Workspace role to Grafana role mapping
1716
DRYCC_WORKSPACE_ROLE_MAPPING = {"admin": "Editor", "member": "Editor", "viewer": "Viewer"}
@@ -107,15 +106,15 @@ async def sync_alerting(context: dict, token: dict, userinfo: dict):
107106
The alerts field only controls notification channels (handled in sync_default).
108107
"""
109108
workspace_orgs = context.get("workspace_orgs", {})
110-
alerting_path = os.path.join(os.path.dirname(__file__), "..", "alerting")
109+
alerting_path = Path(__file__).resolve().parent.parent / "alerting"
111110

112111
for ws_name, ws_info in workspace_orgs.items():
113112
org_id = ws_info["org_id"]
114113
ctx = {**context, "org_id": org_id}
115114

116115
async with httpx.AsyncClient() as client:
117-
for filename in os.listdir(alerting_path):
118-
with open(os.path.join(alerting_path, filename)) as f:
116+
for filepath in alerting_path.glob("*.json"):
117+
with filepath.open() as f:
119118
rule = json.load(f)
120119
# Use PUT for idempotent upsert (POST would create duplicates)
121120
resp = await client.put(
@@ -135,23 +134,20 @@ async def sync_alerting(context: dict, token: dict, userinfo: dict):
135134
async def sync_datasources(context: dict, token: dict, userinfo: dict):
136135
"""Create datasources for each workspace org with workspace-specific URLs."""
137136
workspace_orgs = context.get("workspace_orgs", {})
138-
datasources_path = os.path.join(os.path.dirname(__file__), "..", "datasources")
139-
drycc_token = context.get("drycc_token")
137+
datasources_path = Path(__file__).resolve().parent.parent / "datasources"
140138

141139
for ws_name, ws_info in workspace_orgs.items():
142140
org_id = ws_info["org_id"]
143141
ctx = {**context, "org_id": org_id}
144142
headers = _api_headers(ctx, userinfo)
145143

146144
async with httpx.AsyncClient() as client:
147-
for filename in os.listdir(datasources_path):
148-
with open(os.path.join(datasources_path, filename)) as f:
145+
for filepath in datasources_path.glob("*.json"):
146+
with filepath.open() as f:
149147
template = Template(f.read())
150148
datasource = json.loads(template.substitute(
151-
controller_url=DRYCC_CONTROLLER_URL,
152-
workspace=ws_name,
153-
time_interval=DRYCC_GRAFANA_REFRESH,
154-
token=drycc_token
149+
controller_url=settings.drycc_controller_url,
150+
time_interval=settings.drycc_grafana_refresh
155151
))
156152
resp = await client.get(
157153
_api_url(f"/api/datasources/name/{datasource['name']}"), headers=headers)
@@ -174,17 +170,17 @@ async def sync_datasources(context: dict, token: dict, userinfo: dict):
174170
async def sync_dashboards(context: dict, token: dict, userinfo: dict):
175171
"""Create dashboards for each workspace org."""
176172
workspace_orgs = context.get("workspace_orgs", {})
177-
dashboards_path = os.path.join(os.path.dirname(__file__), "..", "dashboards")
173+
dashboards_path = Path(__file__).resolve().parent.parent / "dashboards"
178174

179175
for ws_name, ws_info in workspace_orgs.items():
180176
org_id = ws_info["org_id"]
181177
ctx = {**context, "org_id": org_id}
182178

183179
async with httpx.AsyncClient() as client:
184-
for filename in os.listdir(dashboards_path):
185-
with open(os.path.join(dashboards_path, filename)) as f:
180+
for filepath in dashboards_path.glob("*.json"):
181+
with filepath.open() as f:
186182
dashboard = json.load(f)
187-
dashboard.update({"id": None, "refresh": DRYCC_GRAFANA_REFRESH})
183+
dashboard.update({"id": None, "refresh": settings.drycc_grafana_refresh})
188184
await client.post(
189185
_api_url("/api/dashboards/db"),
190186
headers=_api_headers(ctx, userinfo),
@@ -202,12 +198,12 @@ async def sync_dashboards(context: dict, token: dict, userinfo: dict):
202198
def _api_url(url_path, is_admin=False):
203199
if is_admin:
204200
return "http://{}:{}@localhost:{}{}".format(
205-
os.environ.get('GF_SECURITY_ADMIN_USER'),
206-
os.environ.get('GF_SECURITY_ADMIN_PASSWORD'),
207-
os.environ.get('GF_SERVER_HTTP_PORT', 3000),
208-
url_path,
201+
settings.gf_security_admin_user,
202+
settings.gf_security_admin_password,
203+
settings.gf_server_http_port,
204+
url_path
209205
)
210-
return "http://localhost:{}{}".format(os.environ.get('GF_SERVER_HTTP_PORT', 3000), url_path)
206+
return "http://localhost:{}{}".format(settings.gf_server_http_port, url_path)
211207

212208

213209
def _api_headers(context: dict, userinfo):
@@ -233,7 +229,7 @@ async def _get_workspaces(drycc_token: str) -> list:
233229
headers = {"Authorization": f"Token {drycc_token}"}
234230
async with httpx.AsyncClient() as client:
235231
resp = await client.get(
236-
f"{DRYCC_CONTROLLER_URL}/v2/workspaces", headers=headers)
232+
f"{settings.drycc_controller_url}/v2/workspaces", headers=headers)
237233
resp.raise_for_status()
238234
return resp.json().get("results", [])
239235

@@ -243,7 +239,7 @@ async def _get_workspace_members(workspace_name: str, drycc_token: str) -> list:
243239
headers = {"Authorization": f"Token {drycc_token}"}
244240
async with httpx.AsyncClient() as client:
245241
resp = await client.get(
246-
f"{DRYCC_CONTROLLER_URL}/v2/workspaces/{workspace_name}/members",
242+
f"{settings.drycc_controller_url}/v2/workspaces/{workspace_name}/members",
247243
headers=headers)
248244
resp.raise_for_status()
249245
return resp.json().get("results", [])
@@ -420,7 +416,7 @@ def _build_alertmanager_config(alert_addresses: str) -> str:
420416

421417
async def _upsert_alert_configuration(org_id: int, config: str):
422418
"""Insert or update alert configuration for an org using parameterized query."""
423-
async with await AsyncConnection.connect(os.environ.get("GF_DATABASE_URL")) as conn:
419+
async with await AsyncConnection.connect(settings.gf_database_url) as conn:
424420
async with conn.cursor() as cursor:
425421
await cursor.execute(
426422
"""

rootfs/usr/share/grafana/oauth2/main.py

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import random
22
import string
33
import argparse
4+
import asyncio
5+
import httpx
46
from contextlib import asynccontextmanager
5-
from fastapi import FastAPI, Request
6-
from fastapi.responses import JSONResponse, RedirectResponse
7+
from fastapi import FastAPI, Request, HTTPException
8+
from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse
79
from authlib.integrations.starlette_client import OAuth
810
from starlette.middleware.sessions import SessionMiddleware
911

1012
from hook import startup_hooks, login_hooks, destroy_hooks
13+
from settings import settings
14+
from token import get_token
1115

1216

1317
def randstr(k=32):
@@ -25,11 +29,23 @@ def randstr(k=32):
2529
args = parser.parse_args()
2630

2731

32+
http_client = None
33+
2834
@asynccontextmanager
2935
async def lifespan(app: FastAPI):
36+
global http_client
37+
http_client = httpx.AsyncClient(timeout=30.0)
38+
39+
# Ensure token is initialized at startup
40+
try:
41+
await get_token()
42+
except Exception as e:
43+
raise RuntimeError(f"Failed to initialize token: {e}")
44+
3045
for startup_hook in startup_hooks:
3146
await startup_hook()
3247
yield
48+
await http_client.aclose()
3349
for destroy_hook in destroy_hooks:
3450
await destroy_hook()
3551

@@ -48,6 +64,8 @@ async def lifespan(app: FastAPI):
4864
)
4965

5066

67+
# ── OAuth2 routes (unchanged) ────────────────────────────────────
68+
5169
@app.get("/oauth2/healthz")
5270
async def healthz():
5371
return {"status": "ok"}
@@ -90,6 +108,45 @@ async def oauth2_userinfo(request: Request):
90108
return JSONResponse(content=userinfo, headers=headers)
91109

92110

111+
# ── Proxy routes (zero-overhead version) ───────────────────────────
112+
113+
async def _proxy_request(request: Request, url: str):
114+
"""Minimal proxy: get the token directly from token.py and use it."""
115+
try:
116+
token = await get_token()
117+
except RuntimeError as e:
118+
raise HTTPException(status_code=503, detail=str(e))
119+
120+
headers = dict(request.headers)
121+
headers.pop("host", None)
122+
headers["Authorization"] = f"Bearer {token}"
123+
124+
resp = await http_client.request(
125+
method=request.method,
126+
url=url,
127+
headers=headers,
128+
params=request.query_params,
129+
content=await request.body(),
130+
)
131+
return StreamingResponse(
132+
resp.aiter_bytes(),
133+
status_code=resp.status_code,
134+
headers=dict(resp.headers),
135+
)
136+
137+
138+
@app.api_route("/proxy/prometheus/{workspace}/{path:path}", methods=["GET", "POST", "PUT", "DELETE"])
139+
async def proxy_prometheus(workspace: str, path: str, request: Request):
140+
url = f"{settings.drycc_controller_url}/v2/prometheus/{workspace}/{path}"
141+
return await _proxy_request(request, url)
142+
143+
144+
@app.api_route("/proxy/quickwit/{workspace}/{path:path}", methods=["GET", "POST", "PUT", "DELETE"])
145+
async def proxy_quickwit(workspace: str, path: str, request: Request):
146+
url = f"{settings.drycc_controller_url}/v2/quickwit/{workspace}/{path}"
147+
return await _proxy_request(request, url)
148+
149+
93150
if __name__ == "__main__":
94151
import uvicorn
95152
uvicorn.run(app, host=args.bind, port=int(args.port))

rootfs/usr/share/grafana/oauth2/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ psycopg[binary]>=3.2.9
77
python-jose>=3.3.0
88
python-multipart>=0.0.5
99
itsdangerous>=2.2.0
10+
pydantic_settings>=2.14.1
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from pydantic import Field, computed_field, RedisDsn, HttpUrl
2+
from pydantic_settings import BaseSettings, SettingsConfigDict
3+
4+
5+
class Settings(BaseSettings):
6+
model_config = SettingsConfigDict(
7+
env_prefix="",
8+
extra="ignore",
9+
case_sensitive=True,
10+
)
11+
12+
# Drycc configuration
13+
drycc_valkey_url: RedisDsn = Field(default="redis://localhost:6379/0", validation_alias="DRYCC_VALKEY_URL")
14+
drycc_passport_url: HttpUrl = Field(default="http://passport.drycc.cc", validation_alias="DRYCC_PASSPORT_URL")
15+
drycc_passport_key: str = Field(default="", validation_alias="DRYCC_PASSPORT_KEY")
16+
drycc_passport_secret: str = Field(default="", validation_alias="DRYCC_PASSPORT_SECRET")
17+
drycc_controller_url: HttpUrl = Field(default="http://controller.drycc.cc", validation_alias="DRYCC_CONTROLLER_URL")
18+
drycc_grafana_refresh: str = Field(default="60s", validation_alias="DRYCC_GRAFANA_REFRESH")
19+
20+
# Grafana configuration
21+
gf_security_admin_user: str = Field(default="admin", validation_alias="GF_SECURITY_ADMIN_USER")
22+
gf_security_admin_password: str = Field(default="admin", validation_alias="GF_SECURITY_ADMIN_PASSWORD")
23+
gf_server_http_port: int = Field(default=3000, validation_alias="GF_SERVER_HTTP_PORT")
24+
gf_database_url: str = Field(default="", validation_alias="GF_DATABASE_URL")
25+
26+
@computed_field
27+
@property
28+
def passport_token_url(self) -> str:
29+
return f"{str(self.drycc_passport_url).rstrip('/')}/oauth/token/"
30+
31+
32+
settings = Settings()

0 commit comments

Comments
 (0)