Skip to content

feat(auth): internal_user role + sandbox template RBAC#116

Open
krrish-berri-2 wants to merge 4 commits into
mainfrom
feat/rbac-internal-user
Open

feat(auth): internal_user role + sandbox template RBAC#116
krrish-berri-2 wants to merge 4 commits into
mainfrom
feat/rbac-internal-user

Conversation

@krrish-berri-2
Copy link
Copy Markdown
Contributor

Summary

  • Two-role auth model: admin (MASTER_KEY) and internal_user (INTERNAL_USER_PASSWORD)
  • internal_user gets agent CRUD but 403 on sandbox template routes
  • New POST /api/ui/auth/internal-user — exchanges username+password for a bearer token
  • New GET /api/ui/auth/config — exposes hasInternalUser flag so login UI can show a "User" tab
  • GET /api/v1/templates gated behind assertAdminAuth
  • docs/rbac.md — covers roles, env var setup, token exchange, and restrictions

Setup

INTERNAL_USER_USERNAME=alice
INTERNAL_USER_PASSWORD=<strong-secret>

Test plan

  • Bearer $MASTER_KEY → role admin, full access including /api/v1/templates
  • Bearer $INTERNAL_USER_PASSWORD → role internal_user, 200 on agent/session routes, 403 on /api/v1/templates
  • Omitting both env vars → 404 on /api/ui/auth/internal-user (not configured)
  • Wrong credentials → 401
  • GET /api/ui/auth/config returns hasInternalUser: true when username is set

🤖 Generated with Claude Code

krrish-berri-2 and others added 4 commits May 15, 2026 14:49
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-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 15, 2026

Greptile Summary

This PR introduces a two-role auth model (admin via MASTER_KEY, internal_user via INTERNAL_USER_PASSWORD) with a token-exchange endpoint and a public config endpoint for the login UI. The templates list route is correctly gated behind the new assertAdminAuth, but several existing routes that use the unchanged assertAuth are now unintentionally accessible to internal users — most critically the cookie-install endpoint, which hands back the MASTER_KEY in a Set-Cookie header.

  • Privilege escalation (src/app/api/ui/auth/cookie/route.ts): POST /api/ui/auth/cookie still uses assertAuth, which now accepts internal_user tokens. On success it writes encodeURIComponent(env.MASTER_KEY) into the Set-Cookie header — any internal user can read that header and use the decoded value as a full admin bearer token.
  • Admin stats information disclosure (src/app/api/v1/admin/stats/route.ts): Also still on assertAuth; internal users can read K8s namespace, image, NodePort range, and warm-pool details that belong behind assertAdminAuth.
  • Timing oracle in credential check (src/app/api/ui/auth/internal-user/route.ts): The || short-circuit skips the password safeEqual when the username fails, creating a distinguishable timing difference; both comparisons should always run unconditionally.

Confidence Score: 1/5

Not 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 assertAdminAuth alongside the rest of the role changes. Because it still calls the original assertAuth, any internal user can trigger it and read the Set-Cookie header that carries the MASTER_KEY in URL-encoded form. The entire RBAC boundary introduced by this PR collapses at that single call site.

src/app/api/ui/auth/cookie/route.ts must be fixed before this merges. src/app/api/v1/admin/stats/route.ts also needs a second look to decide whether its data should be visible to internal users.

Security Review

  • Privilege escalation — src/app/api/ui/auth/cookie/route.ts: POST /api/ui/auth/cookie accepts internal_user tokens (via assertAuth) and responds with a Set-Cookie header containing encodeURIComponent(MASTER_KEY). An internal user can decode this value and use it as a full admin bearer token, bypassing all assertAdminAuth restrictions.
  • Information disclosure — src/app/api/v1/admin/stats/route.ts: GET /api/v1/admin/stats is accessible to internal users and returns K8s namespace, harness image, NodePort range, reconcile cadence, and warm-pool state.
  • Timing oracle — src/app/api/ui/auth/internal-user/route.ts: Short-circuit || in the credential comparison means the password check is skipped when the username fails, leaking username validity through response timing.
  • Username enumeration — src/app/api/ui/auth/config/route.ts: The unauthenticated config endpoint exposes internalUsername verbatim, removing username secrecy from the credential pair.

Important Files Changed

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

Comment on lines +40 to +42
if (!safeEqual(gotUser, username) || !safeEqual(gotPass, password)) {
return Response.json({ error: "invalid credentials" }, { status: 401 });
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security 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.

Suggested change
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)

Comment on lines +16 to +19
return Response.json({
hasInternalUser: Boolean(username),
internalUsername: username ?? null,
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
return Response.json({
hasInternalUser: Boolean(username),
internalUsername: username ?? null,
});
return Response.json({
hasInternalUser: Boolean(username),
});

Comment thread src/server/auth.ts
Comment on lines +46 to +51
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);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 safeEqual is defined identically here and in src/app/api/ui/auth/internal-user/route.ts. Centralising it in a shared server utility (e.g., src/server/crypto.ts) eliminates the duplication and ensures both call sites stay in sync if the implementation ever needs to change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant