Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
5823c39
feat(worker-utils): add worker admin capability contract
pandemicsyn Nov 2, 2026
944eee6
feat(gastown): read worker admin capabilities and harden legacy claims
pandemicsyn Nov 2, 2026
43ffebf
feat(wasteland): read worker admin capabilities and harden legacy claims
pandemicsyn Nov 2, 2026
87a53ae
feat(admin): mint and introspect scoped worker admin capabilities
pandemicsyn Nov 2, 2026
c7e1e0f
refactor(admin): drop dead isAdmin delegation path and classify new s…
pandemicsyn Nov 2, 2026
65eabfa
feat(admin): issue per-procedure capability for worker support calls
pandemicsyn Nov 2, 2026
53df3d9
Merge remote-tracking branch 'origin/feat/admin-assurance-inventory' …
pandemicsyn Nov 2, 2026
b34e4e1
fix(admin): report why a hardened legacy claim was rejected
pandemicsyn Nov 2, 2026
41fc635
fix(admin): reject invalid capability headers on workers
pandemicsyn Nov 2, 2026
515695a
feat(admin): emit tRPC worker admin telemetry
pandemicsyn Nov 2, 2026
58f522a
refactor(admin): clamp capability ttl to the browser bound
pandemicsyn Nov 2, 2026
a212f39
fix(worker-utils): make capability module portable for all typecheck …
pandemicsyn Nov 2, 2026
0b873a8
fix(gastown): bind admin bypass to the named town and gate capability…
pandemicsyn Nov 2, 2026
3b60e94
fix(wasteland): align worker admin telemetry and cover ownership deci…
pandemicsyn Nov 2, 2026
22f1d21
fix(admin): require a session step-up before minting worker capabilities
pandemicsyn Nov 2, 2026
e0ee250
test(gastown): exercise real procedures and audit only used bypasses
pandemicsyn Nov 2, 2026
b17f9cf
test(wasteland): cover real admin procedures and align telemetry
pandemicsyn Nov 2, 2026
1a4441a
fix(admin): reuse scoped browser capabilities and refuse guard user d…
pandemicsyn Nov 2, 2026
68a7616
chore(workers): widen generated admin env types
pandemicsyn Nov 2, 2026
b29f4b7
test(wasteland): cover owner-only access gate
pandemicsyn Nov 2, 2026
0db16a6
test(wasteland): type the org member fixture
pandemicsyn Nov 2, 2026
5b4d76b
refactor(worker-utils): split the client-safe admin action allowlist
pandemicsyn Nov 2, 2026
f0d4839
fix(gastown): restore admin parity and audit town denials once
pandemicsyn Nov 2, 2026
703a6cb
test(wasteland): cover service-scope rejection
pandemicsyn Nov 2, 2026
ec160d8
fix(admin): filter capability actions and widen concurrent requests
pandemicsyn Nov 2, 2026
dfaff1d
fix(admin): scope town auth by action target and bound caches
pandemicsyn Nov 3, 2026
4e07d97
fix(gastown): bind user-target town routes to the named owner
pandemicsyn Nov 3, 2026
d544c53
fix(gastown): bind user capabilities only after ordinary ownership
pandemicsyn Nov 3, 2026
3122785
fix(gastown): remove the pre-ownership user-target gate
pandemicsyn Nov 3, 2026
0c10c41
test(admin): prove capability expiry re-mint and audit denied user ta…
pandemicsyn Nov 3, 2026
dedbd2c
Merge remote-tracking branch 'origin/feat/admin-mfa-step-up' into fea…
pandemicsyn Nov 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@ ordinary local startup does not require this credential.
- `CLOUD_AGENT_RESOURCE_TOKENS_ENABLED` - Default-off family gate for Cloud Agent Next request control and workflow control tokens; requires the master and exact `true`. [SERVER]
- `GASTOWN_RESOURCE_TOKENS_ENABLED` - Default-off family gate for Gastown control tokens; requires the master and exact `true`. This is not a safe-activation declaration: known ingest-audience and live-token-transport blockers remain deferred. [SERVER]
- `WASTELAND_RESOURCE_TOKENS_ENABLED` - Default-off family gate for Wasteland control tokens; requires the master and exact `true`. [SERVER]
- `WORKER_ADMIN_LEGACY_CLAIM_ISSUANCE` - `on` by default. Controls whether `/api/gastown/token`, `/api/wasteland/token`, Wasteland server resolution, and the admin Gastown REST client still put `isAdmin` on the ordinary Worker identity token. Set to `off` only during the legacy drain; Workers independently cap any legacy `isAdmin` claim at one hour and reject dateless claims. [SERVER]
- `WORKER_ADMIN_CAPABILITY_ISSUANCE` - Default off; requires exact `on`. Enables minting short-lived ES256 Worker admin capabilities from a browser session endpoint. Must stay off until session-bound MFA step-up (PR2) is live; the interim actor adapter always fails closed. [SERVER]
- `WORKER_ADMIN_CAPABILITY_PRIVATE_JWK` - `[SECRET]` ES256 private JWK (with `kid`) used to sign Worker admin capabilities. Never commit a private key; rotate by publishing a new `kid` before signing with it. [SECRET]
- `WORKER_ADMIN_CAPABILITY_PUBLIC_KEYS` - Worker-side JSON JWKS (`{"keys":[...]}`) of public capability keys, including retired keys during rotation. [SERVER]
- `LEGACY_ADMIN_CLAIM_MODE` - Worker-side `honor` (default), `observe`, or `reject`. `honor` keeps legacy `isAdmin` support access; `observe` grants but emits `observe_would_deny`; `reject` stops granting legacy admin. Applies to both Gastown and Wasteland. [SERVER]
- `CHAT_RESOURCE_TOKENS_ENABLED` - Default-off family gate for fresh chat resource issuance; requires the master and exact `true`. Validated modern devices retain chat/event-service/notifications issuance after rollback, capped by one hour and parent expiry. [SERVER]
- `DELEGATED_RESOURCE_TOKENS_ENABLED` - Default-off family gate for explicit API, gateway, and attribution delegation, including the organization user-token resource route; requires the master and exact `true`. Disabled explicit delegation remains unavailable. [SERVER]
- `WORKFLOW_GATEWAY_RESOURCE_TOKENS_ENABLED` - Default-off family gate for server workflow gateway tokens; requires the master and exact `true`. [SERVER]
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@
"form-data": "4.0.6",
"google-auth-library": "10.6.2",
"jotai": "2.18.1",
"jose": "catalog:",
"jotai-minidb": "0.0.8",
"js-yaml": "4.3.2",
"jsonwebtoken": "catalog:",
Expand Down
134 changes: 134 additions & 0 deletions apps/web/src/app/api/gastown/token/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
jest.mock('@kilocode/web-shared/lib/user/server', () => ({
getUserFromAuth: jest.fn(),
}));
jest.mock('@/lib/gastown/feature-flags', () => ({
isGastownEnabled: jest.fn(async () => true),
}));
jest.mock('@kilocode/web-shared/lib/organizations/organizations', () => ({
getUserOrgMemberships: jest.fn(async () => []),
}));
jest.mock('@kilocode/web-shared/lib/tokens', () => ({
generateApiToken: jest.fn(() => 'identity-token'),
}));
jest.mock('@kilocode/web-shared/lib/admin/admin-access-log', () => ({
recordKiloAdminElevationForRequest: jest.fn(async () => undefined),
serviceTarget: (id: string) => `service:${id}`,
}));
jest.mock('@kilocode/web-shared/lib/config.server', () => ({
isWorkerAdminLegacyClaimIssuanceEnabled: jest.fn(() => true),
isWorkerAdminCapabilityIssuanceEnabled: jest.fn(() => true),
INTERNAL_API_SECRET: 'internal-secret',
}));
jest.mock('@/lib/worker-admin/actor-guard', () => ({
requireWorkerAdminActor: jest.fn(),
WorkerAdminActorError: class WorkerAdminActorError extends Error {
constructor(
public status: number,
public code: string,
message: string
) {
super(message);
}
},
}));
jest.mock('@/lib/worker-admin/capability-issuer', () => ({
AdminCapabilityRequestSchema: jest.requireActual('@/lib/worker-admin/capability-issuer')
.AdminCapabilityRequestSchema,
WorkerAdminCapabilityIssuanceError: class WorkerAdminCapabilityIssuanceError extends Error {},
issueWorkerAdminCapability: jest.fn(),
}));

import { getUserFromAuth } from '@kilocode/web-shared/lib/user/server';
import { generateApiToken } from '@kilocode/web-shared/lib/tokens';
import { isWorkerAdminLegacyClaimIssuanceEnabled } from '@kilocode/web-shared/lib/config.server';
import { requireWorkerAdminActor, WorkerAdminActorError } from '@/lib/worker-admin/actor-guard';
import { issueWorkerAdminCapability } from '@/lib/worker-admin/capability-issuer';
import { POST } from './route';

const user = { id: 'user-1', is_admin: true, api_token_pepper: 'pepper-1' };

function request(body?: unknown): Request {
return new Request('https://app.example.test/api/gastown/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? null : JSON.stringify(body),
});
}

beforeEach(() => {
jest.clearAllMocks();
jest.mocked(getUserFromAuth).mockResolvedValue({ user, tokenSource: 'session' } as never);
jest.mocked(isWorkerAdminLegacyClaimIssuanceEnabled).mockReturnValue(true);
});

describe('POST /api/gastown/token', () => {
it('mints the ordinary identity token with the legacy admin claim by default', async () => {
const response = await POST(request());
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({ token: 'identity-token' });
expect(generateApiToken).toHaveBeenCalledWith(
user,
expect.objectContaining({ isAdmin: true, gastownAccess: true }),
{ expiresIn: 3600 }
);
expect(issueWorkerAdminCapability).not.toHaveBeenCalled();
});

it('stops issuing the legacy admin claim when the flag is off', async () => {
jest.mocked(isWorkerAdminLegacyClaimIssuanceEnabled).mockReturnValue(false);
await POST(request());
expect(generateApiToken).toHaveBeenCalledWith(
user,
expect.not.objectContaining({ isAdmin: true }),
{ expiresIn: 3600 }
);
});

it('mints a capability only after the interim actor guard passes', async () => {
jest
.mocked(requireWorkerAdminActor)
.mockRejectedValue(
new WorkerAdminActorError(403, 'step_up_required', 'Fresh multi-factor step-up is required')
);
const response = await POST(
request({ adminCapability: { target: { kind: 'town', id: 'town-1' } } })
);
expect(response.status).toBe(403);
await expect(response.json()).resolves.toMatchObject({ code: 'step_up_required' });
expect(issueWorkerAdminCapability).not.toHaveBeenCalled();
});

it('returns the capability once the guard passes', async () => {
jest.mocked(requireWorkerAdminActor).mockResolvedValue({
actor: { assurance: { userId: 'user-1' } },
sessionBinding: { apiTokenPepper: 'pepper-1', webSessionPepper: null },
} as never);
jest.mocked(issueWorkerAdminCapability).mockResolvedValue({
capability: 'capability-token',
capabilityExpiresAt: '2026-01-01T00:05:00.000Z',
jti: 'jti-1',
actions: ['trpc:gastown.getTown'],
});
const response = await POST(
request({ adminCapability: { target: { kind: 'town', id: 'town-1' } } })
);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
capability: 'capability-token',
capabilityExpiresAt: '2026-01-01T00:05:00.000Z',
});
});

it('ignores an unrelated JSON body like it did before capabilities', async () => {
const response = await POST(request({}));
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({ token: 'identity-token' });
expect(requireWorkerAdminActor).not.toHaveBeenCalled();
});

it('rejects a malformed capability request', async () => {
const response = await POST(request({ adminCapability: { target: { kind: 'nope' } } }));
expect(response.status).toBe(400);
expect(requireWorkerAdminActor).not.toHaveBeenCalled();
});
});
104 changes: 86 additions & 18 deletions apps/web/src/app/api/gastown/token/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ import {
recordKiloAdminElevationForRequest,
serviceTarget,
} from '@kilocode/web-shared/lib/admin/admin-access-log';
import {
isWorkerAdminCapabilityIssuanceEnabled,
isWorkerAdminLegacyClaimIssuanceEnabled,
} from '@kilocode/web-shared/lib/config.server';
import { GASTOWN_ADMIN_AUDIENCE } from '@kilocode/worker-utils/worker-admin-capability';
import {
AdminCapabilityRequestSchema,
WorkerAdminCapabilityIssuanceError,
issueWorkerAdminCapability,
} from '@/lib/worker-admin/capability-issuer';
import { requireWorkerAdminActor, WorkerAdminActorError } from '@/lib/worker-admin/actor-guard';

const ONE_HOUR_SECONDS = 60 * 60;

Expand All @@ -17,30 +28,36 @@ const ONE_HOUR_SECONDS = 60 * 60;
* Mints a short-lived (1 hour) Kilo JWT that the browser can use to
* authenticate directly with the Gastown Cloudflare Worker.
*
* The browser authenticates to this endpoint via the NextAuth session cookie
* (same-origin). The returned token is sent as `Authorization: Bearer <token>`
* to the worker's tRPC endpoint (cross-origin).
*
* Access is controlled by the `gastown-access` PostHog feature flag.
* The JWT includes `gastownAccess`, `isAdmin`, `apiTokenPepper`, and
* `orgMemberships` so the worker can enforce access and check org
* membership without DB round-trips.
* With no body, this is the ordinary identity token. A body of
* `{ adminCapability: { target, actions? } }` additionally mints a scoped,
* short-lived ES256 capability for the exact admin actions on that target.
* Capability minting requires a browser session, an admin role, fresh step-up,
* and the WORKER_ADMIN_CAPABILITY_ISSUANCE flag.
*/
export async function POST() {
export async function POST(request: Request) {
const { user, authFailedResponse, tokenSource } = await getUserFromAuth({ adminOnly: false });
if (authFailedResponse) return authFailedResponse;
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

const hasAccess = await isGastownEnabled(user.id);

if (!hasAccess) {
return NextResponse.json({ error: 'Gastown access denied' }, { status: 403 });
}

if (user.is_admin) {
// The minted token carries `isAdmin`, so the elevation is exercised inside
// the Gastown worker where this app emits nothing. Correlate on
// `kiloUserId` within the token's lifetime below.
const rawBody: unknown = await request.json().catch(() => null);
const hasCapabilityRequest =
typeof rawBody === 'object' && rawBody !== null && 'adminCapability' in rawBody;
const capabilityRequest = hasCapabilityRequest
? AdminCapabilityRequestSchema.safeParse(rawBody)
: null;
if (capabilityRequest !== null && !capabilityRequest.success) {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}

const orgMemberships = await getUserOrgMemberships(user.id);
const legacyIssuance = isWorkerAdminLegacyClaimIssuanceEnabled();

if (legacyIssuance && user.is_admin && capabilityRequest === null) {
Comment thread
pandemicsyn marked this conversation as resolved.
Resolved
await recordKiloAdminElevationForRequest({
user,
tokenSource,
Expand All @@ -49,14 +66,65 @@ export async function POST() {
});
}

const orgMemberships = await getUserOrgMemberships(user.id);

const token = generateApiToken(
user,
{ isAdmin: user.is_admin, gastownAccess: true, orgMemberships },
{
...(legacyIssuance && user.is_admin ? { isAdmin: true } : {}),
gastownAccess: true,
orgMemberships,
},
{ expiresIn: ONE_HOUR_SECONDS }
);
const expiresAt = new Date(Date.now() + 55 * 60 * 1000).toISOString();

return NextResponse.json({ token, expiresAt });
if (capabilityRequest !== null) {
const { target, actions } = capabilityRequest.data.adminCapability;
try {
const actor = await requireWorkerAdminActor(request, request.headers, {
actionClass: 'worker_admin',
target: { kind: 'service', id: 'gastown' },
inventoryId: 'rest.gastownToken',
});
const issued = await issueWorkerAdminCapability({
user: {
...user,
id: actor.actor.assurance.userId,
api_token_pepper: actor.sessionBinding.apiTokenPepper,
},
audience: GASTOWN_ADMIN_AUDIENCE,
target,
actions,
headersList: request.headers,
});
return NextResponse.json({
token,
expiresAt,
admin: user.is_admin === true,
capabilityIssuance: true,
capability: issued.capability,
capabilityExpiresAt: issued.capabilityExpiresAt,
});
} catch (error) {
if (error instanceof WorkerAdminActorError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: error.status }
);
}
if (error instanceof WorkerAdminCapabilityIssuanceError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: error.status }
);
}
throw error;
}
}

return NextResponse.json({
token,
expiresAt,
admin: user.is_admin === true,
capabilityIssuance: isWorkerAdminCapabilityIssuanceEnabled(),
});
}
Loading
Loading