feat(auth): internal_user role + sandbox template RBAC#116
feat(auth): internal_user role + sandbox template RBAC#116krrish-berri-2 wants to merge 4 commits into
Conversation
Two-role auth model: - admin (MASTER_KEY) — full access - internal_user (INTERNAL_USER_PASSWORD) — agent CRUD only, 403 on templates Set INTERNAL_USER_USERNAME + INTERNAL_USER_PASSWORD env vars to enable. Token obtained via POST /api/ui/auth/internal-user. GET /api/ui/auth/config exposes hasInternalUser flag for login UI tab. Adds docs/rbac.md covering roles, setup, and template restrictions. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Without this, git clone and curl fail with "CAfile: none" when HTTPS_PROXY routes through the vault sidecar — the proxy's self-signed cert isn't trusted. The shared entrypoint-common.sh sets GIT_SSL_CAINFO to the OS bundle, so the CA must be in that bundle at image build time. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Two bugs preventing the CI smoke test from ever working: 1. scripts/ directory was gitignored entirely — smoke-test-deploy.py was never committed, so the CI step always failed. Replaced the blanket scripts/ ignore with per-pattern rules for dev-only files (*.sh, *.ts, dashboard.py) so smoke-test-deploy.py can be tracked. 2. Raw TCP socket broke on HTTPS ALB_URL. Replaced deprecated ssl.wrap_socket with ssl.create_default_context().wrap_socket(). Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Greptile SummaryThis PR introduces a two-role auth model (
Confidence Score: 1/5Not safe to merge — an internal user can retrieve the MASTER_KEY from a response header and gain full admin access. The cookie-install endpoint was not updated to use
|
| Filename | Overview |
|---|---|
| src/app/api/ui/auth/cookie/route.ts | Uses assertAuth which now accepts internal_user tokens, but responds by setting a cookie containing the MASTER_KEY — internal users can extract it and escalate to admin. |
| src/server/auth.ts | Core auth refactor adds internal_user role and assertAdminAuth; logic is correct but safeEqual is duplicated with internal-user/route.ts. |
| src/app/api/ui/auth/internal-user/route.ts | New token exchange endpoint; ` |
| src/app/api/v1/admin/stats/route.ts | Still uses assertAuth; internal users can now read K8s/platform infrastructure details — should be gated with assertAdminAuth. |
| src/app/api/ui/auth/config/route.ts | Publicly exposes internalUsername in addition to the hasInternalUser boolean — leaks the exact username to unauthenticated callers. |
| src/app/api/v1/templates/route.ts | Correctly upgraded from assertAuth to assertAdminAuth to gate template listing behind admin role. |
| src/server/env.ts | Adds optional INTERNAL_USER_USERNAME and INTERNAL_USER_PASSWORD env vars with no minimum length constraint on the password. |
| src/server/types.ts | Adds role field to AuthIdentity and optional env var types — straightforward type update. |
Reviews (1): Last reviewed commit: "fix(smoke-test): TLS socket for HTTPS + ..." | Re-trigger Greptile
| if (!safeEqual(gotUser, username) || !safeEqual(gotPass, password)) { | ||
| return Response.json({ error: "invalid credentials" }, { status: 401 }); | ||
| } |
There was a problem hiding this comment.
The
|| short-circuit means the password comparison is skipped entirely when the username check fails (either length mismatch or wrong value). This creates a measurable timing difference between "wrong username" (one check) and "correct username + wrong password" (two checks), leaking whether the username is valid. Both checks should always run unconditionally.
| if (!safeEqual(gotUser, username) || !safeEqual(gotPass, password)) { | |
| return Response.json({ error: "invalid credentials" }, { status: 401 }); | |
| } | |
| const userOk = safeEqual(gotUser, username); | |
| const passOk = safeEqual(gotPass, password); | |
| if (!userOk || !passOk) { | |
| return Response.json({ error: "invalid credentials" }, { status: 401 }); | |
| } |
Rule Used: What: Fail any PR which may contains a security in... (source)
| return Response.json({ | ||
| hasInternalUser: Boolean(username), | ||
| internalUsername: username ?? null, | ||
| }); |
There was a problem hiding this comment.
The endpoint exposes
internalUsername without authentication. While the intent is to drive the "User" login tab in the UI, broadcasting the exact username publicly means any unauthenticated client can learn it and target it — reducing the internal-user credential to password-only. Returning only the boolean hasInternalUser is sufficient for the UI decision; omit the username to keep it undiscoverable.
| return Response.json({ | |
| hasInternalUser: Boolean(username), | |
| internalUsername: username ?? null, | |
| }); | |
| return Response.json({ | |
| hasInternalUser: Boolean(username), | |
| }); |
| function safeEqual(a: string, b: string): boolean { | ||
| const ba = Buffer.from(a); | ||
| const bb = Buffer.from(b); | ||
| if (ba.length !== bb.length) return false; | ||
| return timingSafeEqual(ba, bb); | ||
| } |
There was a problem hiding this comment.
Summary
admin(MASTER_KEY) andinternal_user(INTERNAL_USER_PASSWORD)internal_usergets agent CRUD but 403 on sandbox template routesPOST /api/ui/auth/internal-user— exchanges username+password for a bearer tokenGET /api/ui/auth/config— exposeshasInternalUserflag so login UI can show a "User" tabGET /api/v1/templatesgated behindassertAdminAuthdocs/rbac.md— covers roles, env var setup, token exchange, and restrictionsSetup
Test plan
Bearer $MASTER_KEY→ roleadmin, full access including/api/v1/templatesBearer $INTERNAL_USER_PASSWORD→ roleinternal_user, 200 on agent/session routes, 403 on/api/v1/templates/api/ui/auth/internal-user(not configured)GET /api/ui/auth/configreturnshasInternalUser: truewhen username is set🤖 Generated with Claude Code