diff --git a/apps/web/src/app/(app)/organizations/[id]/integrations/linear/reinstall/route.ts b/apps/web/src/app/(app)/organizations/[id]/integrations/linear/reinstall/route.ts index 7946ea5b58f..4e23684ee8e 100644 --- a/apps/web/src/app/(app)/organizations/[id]/integrations/linear/reinstall/route.ts +++ b/apps/web/src/app/(app)/organizations/[id]/integrations/linear/reinstall/route.ts @@ -13,7 +13,7 @@ type RouteContext = { export async function GET(request: NextRequest, context: RouteContext) { const { id } = await context.params; - const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false }); + const { user, tokenSource, authFailedResponse } = await getUserFromAuth({ adminOnly: false }); if (authFailedResponse || !user) { const signInUrl = new URL('/users/sign_in', request.url); signInUrl.searchParams.set('callbackPath', request.nextUrl.pathname); @@ -21,7 +21,7 @@ export async function GET(request: NextRequest, context: RouteContext) { } try { - await ensureOrganizationAccess({ user }, id, ORGANIZATION_BILLING_ROLES); + await ensureOrganizationAccess({ user, tokenSource }, id, ORGANIZATION_BILLING_ROLES); await requireActiveSubscriptionOrTrial(id); } catch { return NextResponse.redirect(new URL('/integrations?error=unauthorized', request.url)); diff --git a/apps/web/src/app/(app)/organizations/[id]/integrations/slack/reinstall/route.ts b/apps/web/src/app/(app)/organizations/[id]/integrations/slack/reinstall/route.ts index db9780bbcee..e25460a0f8f 100644 --- a/apps/web/src/app/(app)/organizations/[id]/integrations/slack/reinstall/route.ts +++ b/apps/web/src/app/(app)/organizations/[id]/integrations/slack/reinstall/route.ts @@ -12,7 +12,7 @@ type RouteContext = { export async function GET(request: NextRequest, context: RouteContext) { const { id } = await context.params; - const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false }); + const { user, tokenSource, authFailedResponse } = await getUserFromAuth({ adminOnly: false }); if (authFailedResponse || !user) { const signInUrl = new URL('/users/sign_in', request.url); signInUrl.searchParams.set('callbackPath', request.nextUrl.pathname); @@ -20,7 +20,7 @@ export async function GET(request: NextRequest, context: RouteContext) { } try { - await ensureOrganizationAccess({ user }, id, ORGANIZATION_MANAGE_ROLES); + await ensureOrganizationAccess({ user, tokenSource }, id, ORGANIZATION_MANAGE_ROLES); } catch { return NextResponse.redirect( new URL(`/organizations/${id}/integrations/slack?error=permission_required`, request.url) diff --git a/apps/web/src/app/api/auto-routing/mode/route.ts b/apps/web/src/app/api/auto-routing/mode/route.ts index cd91ec25b8d..40b99bae6fb 100644 --- a/apps/web/src/app/api/auto-routing/mode/route.ts +++ b/apps/web/src/app/api/auto-routing/mode/route.ts @@ -41,7 +41,7 @@ async function resolveOwner( request: NextRequest, roles?: Parameters[2] ): Promise }> { - const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false }); + const { user, tokenSource, authFailedResponse } = await getUserFromAuth({ adminOnly: false }); if (!user || authFailedResponse) { return { response: NextResponse.json({ error: 'Authentication required' }, { status: 401 }) }; } @@ -55,7 +55,7 @@ async function resolveOwner( } try { - await ensureOrganizationAccess({ user }, organizationId, roles); + await ensureOrganizationAccess({ user, tokenSource }, organizationId, roles); } catch (error) { const response = trpcErrorResponse(error); if (response) return { response }; diff --git a/apps/web/src/app/api/claw/openclaw-export/route.ts b/apps/web/src/app/api/claw/openclaw-export/route.ts index 5887a495e38..71e70d0aea8 100644 --- a/apps/web/src/app/api/claw/openclaw-export/route.ts +++ b/apps/web/src/app/api/claw/openclaw-export/route.ts @@ -43,7 +43,7 @@ function jsonError(message: string, status: number, code?: string): Response { export async function POST(request: NextRequest): Promise { await connection(); - const { user, authFailedResponse } = await getUserFromAuth({ + const { user, tokenSource, authFailedResponse } = await getUserFromAuth({ adminOnly: false, }); if (authFailedResponse) { @@ -75,7 +75,7 @@ export async function POST(request: NextRequest): Promise { let instance: Awaited>; try { if (organizationId) { - await ensureOrganizationAccess({ user } as TRPCContext, organizationId); + await ensureOrganizationAccess({ user, tokenSource } as TRPCContext, organizationId); await requireActiveSubscriptionOrTrial(organizationId); instance = await getActiveOrgInstance(user.id, organizationId); } else { diff --git a/apps/web/src/app/api/cloud-agent/sessions/stream-ticket/route.ts b/apps/web/src/app/api/cloud-agent/sessions/stream-ticket/route.ts index 0862a86c68e..5ef82d9ba40 100644 --- a/apps/web/src/app/api/cloud-agent/sessions/stream-ticket/route.ts +++ b/apps/web/src/app/api/cloud-agent/sessions/stream-ticket/route.ts @@ -51,7 +51,7 @@ function handleTRPCError(error: unknown): NextResponse { */ export async function POST(request: Request) { try { - const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false }); + const { user, tokenSource, authFailedResponse } = await getUserFromAuth({ adminOnly: false }); if (authFailedResponse) { return authFailedResponse; @@ -82,7 +82,7 @@ export async function POST(request: Request) { if (organizationId) { // Organization context - await ensureOrganizationAccess({ user }, organizationId); + await ensureOrganizationAccess({ user, tokenSource }, organizationId); const sessionOwnership = await verifyOrgOwnsSessionByCloudAgentId( db, diff --git a/apps/web/src/app/api/integrations/google/callback/route.ts b/apps/web/src/app/api/integrations/google/callback/route.ts index 4e935d634c1..19d3e041efd 100644 --- a/apps/web/src/app/api/integrations/google/callback/route.ts +++ b/apps/web/src/app/api/integrations/google/callback/route.ts @@ -98,7 +98,7 @@ function oauthSentryContext(searchParams: URLSearchParams): { */ export async function GET(request: NextRequest) { try { - const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false }); + const { user, tokenSource, authFailedResponse } = await getUserFromAuth({ adminOnly: false }); if (authFailedResponse) { return NextResponse.redirect(new URL('/users/sign_in', APP_URL)); } @@ -133,7 +133,7 @@ export async function GET(request: NextRequest) { } if (verifiedState.owner.type === 'org') { - await ensureOrganizationAccess({ user }, verifiedState.owner.id); + await ensureOrganizationAccess({ user, tokenSource }, verifiedState.owner.id); } else if (verifiedState.owner.id !== user.id) { return NextResponse.redirect(new URL('/claw/settings?error=unauthorized', APP_URL)); } diff --git a/apps/web/src/app/api/integrations/google/connect/route.ts b/apps/web/src/app/api/integrations/google/connect/route.ts index 1755a86acaf..a8c62f15ec7 100644 --- a/apps/web/src/app/api/integrations/google/connect/route.ts +++ b/apps/web/src/app/api/integrations/google/connect/route.ts @@ -39,7 +39,7 @@ export async function GET(request: NextRequest) { let organizationId: string | undefined; try { - const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false }); + const { user, tokenSource, authFailedResponse } = await getUserFromAuth({ adminOnly: false }); if (authFailedResponse) { return NextResponse.redirect(new URL('/users/sign_in', APP_URL)); } @@ -53,7 +53,7 @@ export async function GET(request: NextRequest) { return NextResponse.redirect(new URL('/claw/settings?error=invalid_organization', APP_URL)); } organizationId = parsedOrgId.data; - await ensureOrganizationAccess({ user }, organizationId); + await ensureOrganizationAccess({ user, tokenSource }, organizationId); await requireOrganizationKiloClawComputeEntitlement(organizationId); } else { await requireKiloClawAccess(user.id); diff --git a/apps/web/src/app/api/integrations/google/disconnect/route.ts b/apps/web/src/app/api/integrations/google/disconnect/route.ts index ca5b0fb2a3f..49e32339e06 100644 --- a/apps/web/src/app/api/integrations/google/disconnect/route.ts +++ b/apps/web/src/app/api/integrations/google/disconnect/route.ts @@ -42,7 +42,7 @@ export async function POST(request: NextRequest) { } // Disconnect revokes stored OAuth credentials, so authenticated owners can perform it after billing access expires. - const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false }); + const { user, tokenSource, authFailedResponse } = await getUserFromAuth({ adminOnly: false }); if (authFailedResponse) { return NextResponse.redirect(new URL('/users/sign_in', APP_URL), 303); } @@ -57,7 +57,7 @@ export async function POST(request: NextRequest) { ); } organizationId = parsedOrgId.data; - await ensureOrganizationAccess({ user }, organizationId); + await ensureOrganizationAccess({ user, tokenSource }, organizationId); } const instance = organizationId diff --git a/apps/web/src/app/api/organizations/[id]/defaults/route.ts b/apps/web/src/app/api/organizations/[id]/defaults/route.ts index b797f8a465e..25103228e16 100644 --- a/apps/web/src/app/api/organizations/[id]/defaults/route.ts +++ b/apps/web/src/app/api/organizations/[id]/defaults/route.ts @@ -24,11 +24,13 @@ type DefaultsResponse = { }; export async function GET( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ id: string }> } ): Promise> { const organizationId = (await params).id; - const { success, data, nextResponse } = await getAuthorizedOrgContext(organizationId); + const { success, data, nextResponse } = await getAuthorizedOrgContext(organizationId, undefined, { + requestMethod: request.method, + }); if (!success) { return nextResponse; } diff --git a/apps/web/src/app/api/organizations/[id]/guardrails/mcp-connections/route.ts b/apps/web/src/app/api/organizations/[id]/guardrails/mcp-connections/route.ts index 4b1c6e97c70..4f9bbfacd6f 100644 --- a/apps/web/src/app/api/organizations/[id]/guardrails/mcp-connections/route.ts +++ b/apps/web/src/app/api/organizations/[id]/guardrails/mcp-connections/route.ts @@ -43,7 +43,9 @@ export async function POST( { params }: { params: Promise<{ id: string }> } ): Promise> { const organizationId = (await params).id; - const { success, data, nextResponse } = await getAuthorizedOrgContext(organizationId); + const { success, data, nextResponse } = await getAuthorizedOrgContext(organizationId, undefined, { + requestMethod: request.method, + }); if (!success) { return nextResponse; } diff --git a/apps/web/src/app/api/organizations/[id]/guardrails/route.ts b/apps/web/src/app/api/organizations/[id]/guardrails/route.ts index 5c8df5e0cbd..43ac9a7422f 100644 --- a/apps/web/src/app/api/organizations/[id]/guardrails/route.ts +++ b/apps/web/src/app/api/organizations/[id]/guardrails/route.ts @@ -13,11 +13,13 @@ import { * everything off. */ export async function GET( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ id: string }> } ): Promise> { const organizationId = (await params).id; - const { success, data, nextResponse } = await getAuthorizedOrgContext(organizationId); + const { success, data, nextResponse } = await getAuthorizedOrgContext(organizationId, undefined, { + requestMethod: request.method, + }); if (!success) { return nextResponse; } diff --git a/apps/web/src/app/api/organizations/[id]/modes/route.ts b/apps/web/src/app/api/organizations/[id]/modes/route.ts index 5d483dded14..099e3ce9848 100644 --- a/apps/web/src/app/api/organizations/[id]/modes/route.ts +++ b/apps/web/src/app/api/organizations/[id]/modes/route.ts @@ -5,11 +5,13 @@ import type { OrganizationMode } from '@/lib/organizations/organization-modes'; import { getAllOrganizationModes } from '@/lib/organizations/organization-modes'; export async function GET( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ id: string }> } ): Promise> { const organizationId = (await params).id; - const { success, data, nextResponse } = await getAuthorizedOrgContext(organizationId); + const { success, data, nextResponse } = await getAuthorizedOrgContext(organizationId, undefined, { + requestMethod: request.method, + }); if (!success) { return nextResponse; } diff --git a/apps/web/src/app/api/organizations/[id]/user-tokens/route.auth.test.ts b/apps/web/src/app/api/organizations/[id]/user-tokens/route.auth.test.ts index a88987f2a0f..07db30a41b0 100644 --- a/apps/web/src/app/api/organizations/[id]/user-tokens/route.auth.test.ts +++ b/apps/web/src/app/api/organizations/[id]/user-tokens/route.auth.test.ts @@ -139,7 +139,7 @@ for (const enabled of [false, true]) { describe(`legacy organization issuance with resource flags ${enabled}`, () => { beforeEach(() => setIssuance(enabled)); - test.each(['cookie', 'empty-header-cookie', 'legacy-1h', 'legacy-5y'] as const)( + test.each(['cookie', 'legacy-1h', 'legacy-5y'] as const)( 'preserves %s for absent, empty, malformed and empty-object bodies', async auth => { const headers = auth.startsWith('legacy') @@ -151,7 +151,6 @@ for (const enabled of [false, true]) { ) ) : new Headers({ cookie: 'next-auth.session-token=test-session' }); - if (auth === 'empty-header-cookie') headers.set('authorization', ''); for (const body of [undefined, '', '{', '{}']) { const response = await request(headers, body); expect(response.status).toBe(200); @@ -181,6 +180,18 @@ for (const enabled of [false, true]) { } ); + test('rejects an empty Authorization header even with a valid session cookie', async () => { + const headers = new Headers({ cookie: 'next-auth.session-token=test-session' }); + headers.set('authorization', ''); + + for (const body of [undefined, '', '{', '{}']) { + const response = await request(headers, body); + expect(response.status).toBe(401); + } + + expect(await auditLogs()).toHaveLength(0); + }); + test.each([900, 3600, 7200])( 'preserves legacy human tokens with deviceAuthRequestCode and %s-second lifetime', async expiresIn => { diff --git a/apps/web/src/app/api/organizations/[id]/user-tokens/route.ts b/apps/web/src/app/api/organizations/[id]/user-tokens/route.ts index 0f1b911b13c..db2e344317b 100644 --- a/apps/web/src/app/api/organizations/[id]/user-tokens/route.ts +++ b/apps/web/src/app/api/organizations/[id]/user-tokens/route.ts @@ -46,7 +46,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const organizationId = (await params).id; // Verify user has access to the organization (any member role is sufficient) - const result = await getAuthorizedOrgContext(organizationId); + const result = await getAuthorizedOrgContext(organizationId, undefined, { + requestMethod: request.method, + }); if (!result.success) { return result.nextResponse; diff --git a/apps/web/src/app/payments/topup/route.ts b/apps/web/src/app/payments/topup/route.ts index 1c9bddb8bd1..257729af481 100644 --- a/apps/web/src/app/payments/topup/route.ts +++ b/apps/web/src/app/payments/topup/route.ts @@ -78,7 +78,9 @@ export async function POST(request: NextRequest): Promise> let accountCreatedAt: Date | string = currentUser.created_at; let accountUsedMicrodollars = currentUser.microdollars_used; if (organizationId) { - const orgContext = await getAuthorizedOrgContext(organizationId, ORGANIZATION_BILLING_ROLES); + const orgContext = await getAuthorizedOrgContext(organizationId, ORGANIZATION_BILLING_ROLES, { + requestMethod: request.method, + }); if (!orgContext.success) { return orgContext.nextResponse; } diff --git a/apps/web/src/lib/auth/cloud-agent-authority.test.ts b/apps/web/src/lib/auth/cloud-agent-authority.test.ts index 4ce4b69dc65..e8ebd34be42 100644 --- a/apps/web/src/lib/auth/cloud-agent-authority.test.ts +++ b/apps/web/src/lib/auth/cloud-agent-authority.test.ts @@ -87,7 +87,15 @@ jest.mock('@kilocode/web-shared/lib/admin/admin-access-log', () => ({ authViaTokenFromHeaders: (headers: Headers) => headers.has('authorization'), clientIpFromHeaders: () => null, routeFromHeaders: () => null, + methodFromHeaders: (_headers: Headers, requestMethod?: string | null) => requestMethod ?? null, + userAgentProductFromHeaders: () => null, emitAdminAccessEvent: jest.fn(), + emitPrivilegedAccessEvent: jest.fn(), + recordKiloAdminElevation: jest.fn(), + recordKiloAdminElevationForRequest: jest.fn(), + elevateViaKiloAdmin: async (_ctx: unknown, params: { grant: unknown }) => params.grant, + serviceTarget: (service: string) => `service:${service}`, + organizationTarget: (id: string) => `organization:${id}`, })); jest.mock('@kilocode/web-shared/lib/web-session-revocation', () => ({ isWebSessionCurrent: () => true, diff --git a/apps/web/src/lib/auth/resource-delegation.ts b/apps/web/src/lib/auth/resource-delegation.ts index 9a439a465c4..6af0e31955a 100644 --- a/apps/web/src/lib/auth/resource-delegation.ts +++ b/apps/web/src/lib/auth/resource-delegation.ts @@ -34,6 +34,10 @@ import { import { db } from '@kilocode/web-shared/lib/drizzle'; import { generateApiToken, TOKEN_EXPIRY } from '@kilocode/web-shared/lib/tokens'; import { getUserFromSessionForCredentialIssuance } from '@kilocode/web-shared/lib/user/server'; +import { + recordKiloAdminElevationForRequest, + serviceTarget, +} from '@kilocode/web-shared/lib/admin/admin-access-log'; import { getAuthorizedOrgContext } from '@/lib/organizations/organization-auth'; const ONE_HOUR_SECONDS = 60 * 60; @@ -358,6 +362,16 @@ export async function isModernResourceDelegationRequest(requestHeaders: Headers) return verified.claims.tokenPurpose !== undefined; } +function mintsWorkerAdminToken( + resource: ControlResource, + authority: ResourceDelegationAuthority, + options?: CreateControlTokenOptions +): boolean { + return ( + resource !== 'cloud-agent-next' && options?.extra?.isAdmin === true && authority.user.is_admin + ); +} + async function createModernControlToken( authority: ResourceDelegationAuthority, resource: ControlResource, @@ -373,6 +387,15 @@ async function createModernControlToken( if (expiresIn <= 0) unauthorized('Resource delegation authority has expired'); const memberships = resource === 'cloud-agent-next' ? undefined : await membershipsFor(authority.user.id); + if (mintsWorkerAdminToken(resource, authority, options)) { + await recordKiloAdminElevationForRequest({ + user: authority.user, + tokenSource: options?.tokenSource ?? authority.tokenSource, + reason: 'delegated_resource_token_mint', + target: serviceTarget(resource), + headersList: options?.headers, + }); + } const payload = buildModernKiloTokenPayload({ userId: authority.user.id, pepper: authority.user.api_token_pepper, @@ -480,6 +503,15 @@ export async function createControlTokenForRequest( const expiresIn = options?.legacyExpiresIn ?? (resource === 'cloud-agent-next' ? TOKEN_EXPIRY.default : ONE_HOUR_SECONDS); + if (mintsWorkerAdminToken(resource, authority, options)) { + await recordKiloAdminElevationForRequest({ + user: authority.user, + tokenSource: options?.tokenSource, + reason: 'delegated_resource_token_mint', + target: serviceTarget(resource), + headersList: options?.headers, + }); + } const token = generateApiToken( authority.user, { diff --git a/apps/web/src/lib/organizations/organization-auth.test.ts b/apps/web/src/lib/organizations/organization-auth.test.ts index 3d08a7d6904..39246ece38f 100644 --- a/apps/web/src/lib/organizations/organization-auth.test.ts +++ b/apps/web/src/lib/organizations/organization-auth.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, beforeEach, afterEach } from '@jest/globals'; +import { describe, test, expect, beforeEach, afterEach, jest } from '@jest/globals'; import { getAuthorizedOrgContext } from './organization-auth'; import { db } from '@kilocode/web-shared/lib/drizzle'; import { organization_memberships, organizations } from '@kilocode/db/schema'; @@ -82,6 +82,48 @@ describe('getAuthorizedOrgContext', () => { }); }); + test('records the elevation and still succeeds when the membership query throws', async () => { + const adminUser = await insertTestUser({ is_admin: true }); + const events: AdminAccessEvent[] = []; + setAdminAccessSinkForTest(event => events.push(event)); + + const originalSelect = db.select.bind(db); + const selectSpy = jest.spyOn(db, 'select').mockImplementation((...args) => { + const builder = originalSelect(...args); + return new Proxy(builder, { + get(target, property, receiver) { + if (property === 'from') { + return (table: Parameters[0]) => { + if (table === organization_memberships) { + throw new Error('membership query failed'); + } + return target.from(table); + }; + } + const value = Reflect.get(target, property, receiver); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + }); + + let result: Awaited>; + try { + result = await getAuthorizedOrgContext(testOrganizationId, undefined, { + getUserFromAuthOverride: async () => ({ user: adminUser, authFailedResponse: null }), + }); + } finally { + selectSpy.mockRestore(); + setAdminAccessSinkForTest(null); + } + + expect(result.success).toBe(true); + expect(events.find(event => event.kind === 'kilo_admin_elevation')).toMatchObject({ + kind: 'kilo_admin_elevation', + membershipRole: null, + wouldPassWithoutAdmin: null, + }); + }); + test('should allow admin users even if they are not in organization_memberships', async () => { const adminUser = await insertTestUser({ is_admin: true }); diff --git a/apps/web/src/lib/organizations/organization-auth.ts b/apps/web/src/lib/organizations/organization-auth.ts index 5b7e4a178d2..23c61aa2d67 100644 --- a/apps/web/src/lib/organizations/organization-auth.ts +++ b/apps/web/src/lib/organizations/organization-auth.ts @@ -16,6 +16,10 @@ import { sentryLogger } from '@kilocode/web-shared/lib/utils.server'; import type { CustomResult } from '@kilocode/web-shared/lib/maybe-result'; import { successResult } from '@kilocode/web-shared/lib/maybe-result'; import { getOrganizationById } from '@kilocode/web-shared/lib/organizations/organizations'; +import { + loadEffectiveMembershipRows, + observeMembershipRole, +} from '@kilocode/web-shared/routers/organizations/utils'; import z from 'zod'; const warnInSentry = sentryLogger('org_auth', 'warning'); @@ -55,15 +59,21 @@ export async function getAuthorizedOrgContext( * `'server-component'`. */ surface?: AdminAccessSurface; + /** The actual Request method when the caller has one. */ + requestMethod?: string; } = {} ): Promise> { - const { getUserFromAuthOverride, surface } = options; + const { getUserFromAuthOverride, surface, requestMethod } = options; const getUserFromAuthFn = process.env.NODE_ENV === 'test' && getUserFromAuthOverride ? getUserFromAuthOverride : getUserFromAuth; - const { authFailedResponse, user, tokenSource } = await getUserFromAuthFn({ adminOnly: false }); + const { authFailedResponse, user, tokenSource, internalApiUse, cloudAgentAuthority } = + await getUserFromAuthFn({ + adminOnly: false, + requestMethod, + }); if (authFailedResponse) { return { success: false, nextResponse: authFailedResponse }; } @@ -77,16 +87,28 @@ export async function getAuthorizedOrgContext( const organizationId = data; if (user.is_admin) { - // Recorded before the lookup so an admin probing a non-existent organization - // is still attributed, rather than vanishing into the 404 below. + const organizationResult = getOrganizationById(organizationId).then( + value => ({ ok: true as const, value }), + error => ({ ok: false as const, error }) + ); + const observation = await observeMembershipRole( + () => loadEffectiveMembershipRows(user.id, organizationId), + roles + ); await recordKiloAdminElevationForRequest({ user, tokenSource, + internalApiUse, + delegatedWorkload: cloudAgentAuthority !== undefined, reason: 'organization_access_rest', target: organizationTarget(organizationId), surface, + method: requestMethod, + ...observation, }); - const organization = await getOrganizationById(organizationId); + const lookup = await organizationResult; + if (!lookup.ok) throw lookup.error; + const organization = lookup.value; if (!organization) { const res = NextResponse.json({ error: 'Organization not found' }, { status: 404 }); return { success: false, nextResponse: res }; diff --git a/apps/web/src/lib/trpc/admin-procedures.ts b/apps/web/src/lib/trpc/admin-procedures.ts index 535c3e656cf..baf9ccdf888 100644 --- a/apps/web/src/lib/trpc/admin-procedures.ts +++ b/apps/web/src/lib/trpc/admin-procedures.ts @@ -10,18 +10,21 @@ import { import { userCanManageCredits } from '@/lib/admin/credit-management'; import { db } from '@kilocode/web-shared/lib/drizzle'; import { adminProcedure } from '@kilocode/web-shared/lib/trpc/init'; +import { markPrivilegedGuard } from '@kilocode/web-shared/lib/admin/privileged-inventory'; -export const creditManagerProcedure = adminProcedure.use(async ({ ctx, next }) => { - const currentUser = await getCurrentUserFromPrimary(ctx.user.id); - if (!currentUser || currentUser.blocked_reason !== null || !userCanManageCredits(currentUser)) { - throw new TRPCError({ - code: 'FORBIDDEN', - message: 'Credit management access required', - }); - } +export const creditManagerProcedure = adminProcedure.use( + markPrivilegedGuard(async ({ ctx, next }) => { + const currentUser = await getCurrentUserFromPrimary(ctx.user.id); + if (!currentUser || currentUser.blocked_reason !== null || !userCanManageCredits(currentUser)) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Credit management access required', + }); + } - return next(); -}); + return next(); + }, 'credit_manager') +); async function getCurrentUserFromPrimary(userId: string) { return db.query.kilocode_users.findFirst({ @@ -29,36 +32,42 @@ async function getCurrentUserFromPrimary(userId: string) { }); } -export const superadminProcedure = adminProcedure.use(async ({ ctx, next }) => { - const currentUser = await getCurrentUserFromPrimary(ctx.user.id); - if (!currentUser || currentUser.blocked_reason !== null || !userIsSuperadmin(currentUser)) { - throw new TRPCError({ - code: 'FORBIDDEN', - message: 'Superadmin access required', - }); - } +export const superadminProcedure = adminProcedure.use( + markPrivilegedGuard(async ({ ctx, next }) => { + const currentUser = await getCurrentUserFromPrimary(ctx.user.id); + if (!currentUser || currentUser.blocked_reason !== null || !userIsSuperadmin(currentUser)) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Superadmin access required', + }); + } - return next(); -}); + return next(); + }, 'superadmin') +); -export const sessionViewerProcedure = adminProcedure.use(async ({ ctx, next }) => { - if (!(await canCurrentUserViewSessions(ctx.user.id))) { - throw new TRPCError({ - code: 'FORBIDDEN', - message: 'Session viewing access required', - }); - } +export const sessionViewerProcedure = adminProcedure.use( + markPrivilegedGuard(async ({ ctx, next }) => { + if (!(await canCurrentUserViewSessions(ctx.user.id))) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Session viewing access required', + }); + } - return next(); -}); + return next(); + }, 'session_viewer') +); -export const apiRequestLogExporterProcedure = adminProcedure.use(async ({ ctx, next }) => { - if (!(await canCurrentUserExportApiRequestLogs(ctx.user.id))) { - throw new TRPCError({ - code: 'FORBIDDEN', - message: 'API request log export access required', - }); - } +export const apiRequestLogExporterProcedure = adminProcedure.use( + markPrivilegedGuard(async ({ ctx, next }) => { + if (!(await canCurrentUserExportApiRequestLogs(ctx.user.id))) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'API request log export access required', + }); + } - return next(); -}); + return next(); + }, 'api_request_log_exporter') +); diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts index 49838ad7a90..324fc704328 100644 --- a/apps/web/src/proxy.ts +++ b/apps/web/src/proxy.ts @@ -12,6 +12,7 @@ import { function baseProxy(request: NextRequestWithAuth) { const requestHeaders = new Headers(request.headers); requestHeaders.set('x-pathname', request.nextUrl.pathname); + requestHeaders.set('x-http-method', request.method); // The query, kept separate from the pathname: `appendCallbackPath` re-attaches // it to the sign-in callback so a resume link does not lose its `?at=` anchor // (and the browser does not lose any other query) across the sign-in detour. diff --git a/apps/web/src/routers/__snapshots__/privileged-inventory.test.ts.snap b/apps/web/src/routers/__snapshots__/privileged-inventory.test.ts.snap new file mode 100644 index 00000000000..f297b66b957 --- /dev/null +++ b/apps/web/src/routers/__snapshots__/privileged-inventory.test.ts.snap @@ -0,0 +1,455 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`privileged inventory covers Worker admin gates the Worker admin procedure list is reviewed as a snapshot: worker-admin-procedures 1`] = ` +[ + "gastown.adminBulkDeleteBeads -> worker.gastown.admin", + "gastown.adminDeleteBeadsByStatus -> worker.gastown.admin", + "gastown.adminForceCloseBead -> worker.gastown.admin", + "gastown.adminForceFailBead -> worker.gastown.admin", + "gastown.adminForceResetAgent -> worker.gastown.admin", + "gastown.adminForceRestartContainer -> worker.gastown.admin", + "gastown.adminGetAlarmStatus -> worker.gastown.admin", + "gastown.adminGetBead -> worker.gastown.admin", + "gastown.adminGetTownEvents -> worker.gastown.admin", + "gastown.adminListAgents -> worker.gastown.admin", + "gastown.adminListBeads -> worker.gastown.admin", + "gastown.debugAgentMetadata -> worker.gastown.debugAgentMetadata", + "wasteland.adminListWastelands -> worker.wasteland.admin", +] +`; + +exports[`privileged inventory covers discovered route handlers the privileged route-to-entry mapping is reviewed as a snapshot: privileged-rest-routes 1`] = ` +[ + "/admin/api/abuse/blacklisted-domains read -> rest.admin.abuse", + "/admin/api/abuse/bulk-block write -> rest.admin.abuse", + "/admin/api/account-deduplication read -> rest.admin.accountDeduplication", + "/admin/api/api-request-log/download read -> rest.admin.apiRequestLog", + "/admin/api/auto-routing/benchmark-config read -> rest.admin.autoRouting", + "/admin/api/auto-routing/benchmark-config write -> rest.admin.autoRouting", + "/admin/api/auto-routing/benchmark-registry read -> rest.admin.autoRouting", + "/admin/api/auto-routing/benchmark-registry write -> rest.admin.autoRouting", + "/admin/api/auto-routing/benchmark-routing-table read -> rest.admin.autoRouting", + "/admin/api/auto-routing/benchmark-runs read -> rest.admin.autoRouting", + "/admin/api/auto-routing/benchmark-runs write -> rest.admin.autoRouting", + "/admin/api/auto-routing/benchmark-scheduler-config read -> rest.admin.autoRouting", + "/admin/api/auto-routing/benchmark-scheduler-config write -> rest.admin.autoRouting", + "/admin/api/auto-routing/classifier-analytics read -> rest.admin.autoRouting", + "/admin/api/auto-routing/classifier-model read -> rest.admin.autoRouting", + "/admin/api/auto-routing/classifier-model write -> rest.admin.autoRouting", + "/admin/api/auto-routing/system-one-models read -> rest.admin.autoRouting", + "/admin/api/backfills/block-blacklisted-domains read -> rest.admin.backfills", + "/admin/api/backfills/block-blacklisted-domains write -> rest.admin.backfills", + "/admin/api/backfills/blocked-at read -> rest.admin.backfills", + "/admin/api/backfills/blocked-at write -> rest.admin.backfills", + "/admin/api/backfills/blocked-user-pepper read -> rest.admin.backfills", + "/admin/api/backfills/blocked-user-pepper write -> rest.admin.backfills", + "/admin/api/backfills/email-domain read -> rest.admin.backfills", + "/admin/api/backfills/email-domain write -> rest.admin.backfills", + "/admin/api/backfills/normalized-email read -> rest.admin.backfills", + "/admin/api/backfills/normalized-email write -> rest.admin.backfills", + "/admin/api/credit-categories read -> rest.admin.creditCategories", + "/admin/api/credit-categories/[key] read -> rest.admin.creditCategories", + "/admin/api/enrichment-data/upsert write -> rest.admin.enrichmentData", + "/admin/api/gastown-analytics read -> rest.admin.analytics", + "/admin/api/kiloclaw-analytics read -> rest.admin.kiloclawAnalytics", + "/admin/api/kiloclaw-controller-telemetry read -> rest.admin.kiloclawControllerTelemetry", + "/admin/api/organizations/[id]/consume-credits write -> rest.admin.organizations", + "/admin/api/organizations/[id]/force-expiration-check write -> rest.admin.organizations", + "/admin/api/revenue/daily-stats read -> rest.admin.revenue", + "/admin/api/safety-identifiers read -> rest.admin.safetyIdentifiers", + "/admin/api/safety-identifiers write -> rest.admin.safetyIdentifiers", + "/admin/api/slack-bot/stats read -> rest.admin.slackBot", + "/admin/api/users read -> rest.admin.users", + "/admin/api/users/[id]/dev/insert-usage-record write -> rest.admin.users", + "/admin/api/users/[id]/kill-balance-cache write -> rest.admin.users", + "/admin/api/users/[id]/referrals read -> rest.admin.users", + "/admin/api/users/add-credit write -> rest.admin.users", + "/admin/api/users/credit-transactions read -> rest.admin.users", + "/admin/api/users/gdpr-removal read -> rest.admin.users", + "/admin/api/users/gdpr-removal write -> rest.admin.users", + "/admin/api/users/heuristic-analysis/grouped read -> rest.admin.users", + "/admin/api/users/heuristic-analysis/raw read -> rest.admin.users", + "/admin/api/users/payment-methods read -> rest.admin.users", + "/api/auto-routing/mode read -> rest.autoRouting", + "/api/auto-routing/mode write -> rest.autoRouting", + "/api/claw/openclaw-export write -> rest.claw", + "/api/cloud-agent/sessions/stream-ticket write -> rest.cloudAgent", + "/api/code-indexing/enabled read -> rest.codeIndexing", + "/api/integrations/github/callback read -> rest.integrations", + "/api/integrations/github/connection/callback read -> rest.integrations", + "/api/integrations/google/callback read -> rest.integrations", + "/api/integrations/google/connect read -> rest.integrations", + "/api/integrations/google/disconnect read -> rest.integrations", + "/api/integrations/google/disconnect write -> rest.integrations", + "/api/internal/glanceable-agents-snapshot write -> rest.internal", + "/api/organizations/[id]/defaults read -> rest.orgRoutes", + "/api/organizations/[id]/guardrails read -> rest.orgRoutes", + "/api/organizations/[id]/guardrails/mcp-connections write -> rest.orgRoutes", + "/api/organizations/[id]/modes read -> rest.orgRoutes", + "/api/organizations/[id]/user-tokens write -> rest.organizationTokens", + "/api/private/users read -> rest.private.users", + "/organizations/[id]/integrations/linear/reinstall read -> rest.orgPages", + "/organizations/[id]/integrations/slack/reinstall read -> rest.orgPages", + "/payments/topup write -> rest.payments.topup", +] +`; + +exports[`privileged inventory covers discovered tRPC procedures the guarded procedure list is reviewed as a snapshot: guarded-trpc-procedures 1`] = ` +[ + "admin.actionAuditLogs.list query [admin,superadmin] -> trpc.admin.actionAuditLogs", + "admin.aiAttribution.deleteAttribution mutation [admin] -> trpc.admin.aiAttribution", + "admin.aiAttribution.getDebugData query [admin] -> trpc.admin.aiAttribution", + "admin.aiAttribution.searchBranches query [admin] -> trpc.admin.aiAttribution", + "admin.aiAttribution.searchFilePaths query [admin] -> trpc.admin.aiAttribution", + "admin.aiAttribution.searchProjects query [admin] -> trpc.admin.aiAttribution", + "admin.alerting.deleteConfig mutation [admin] -> trpc.admin.alerting", + "admin.alerting.deleteTtfbConfig mutation [admin] -> trpc.admin.alerting", + "admin.alerting.getBaseline mutation [admin] -> trpc.admin.alerting", + "admin.alerting.getTtfbBaseline mutation [admin] -> trpc.admin.alerting", + "admin.alerting.listConfigs query [admin] -> trpc.admin.alerting", + "admin.alerting.listTtfbConfigs query [admin] -> trpc.admin.alerting", + "admin.alerting.updateConfig mutation [admin] -> trpc.admin.alerting", + "admin.alerting.updateTtfbConfig mutation [admin] -> trpc.admin.alerting", + "admin.apiRequestLog.getSummary query [admin,api_request_log_exporter] -> trpc.admin.apiRequestLog", + "admin.autoFreeConfig.get query [admin] -> trpc.admin.autoFreeConfig", + "admin.autoFreeConfig.set mutation [admin] -> trpc.admin.autoFreeConfig", + "admin.blacklistDomains.get query [admin] -> trpc.admin.blacklistDomains", + "admin.blacklistDomains.set mutation [admin] -> trpc.admin.blacklistDomains", + "admin.blacklistDomains.stats query [admin] -> trpc.admin.blacklistDomains", + "admin.blacklistDomains.suspicious query [admin] -> trpc.admin.blacklistDomains", + "admin.botRequests.dailyUsage query [admin] -> trpc.admin.botRequests", + "admin.botRequests.getById query [admin] -> trpc.admin.botRequests", + "admin.botRequests.list query [admin] -> trpc.admin.botRequests", + "admin.botRequests.newUsersPerDay query [admin] -> trpc.admin.botRequests", + "admin.botRequests.weeklyActiveUsers query [admin] -> trpc.admin.botRequests", + "admin.bulkBlock.recentBlocks query [admin] -> trpc.admin.bulkBlock", + "admin.bulkBlock.unblockRecentBlock mutation [admin] -> trpc.admin.bulkBlock", + "admin.bulkUserCredits.grantBulkCredits mutation [admin,credit_manager] -> trpc.admin.bulkUserCredits", + "admin.bulkUserCredits.matchUsers mutation [admin] -> trpc.admin.bulkUserCredits", + "admin.cloudAgentNext.getHealthOverview query [admin] -> trpc.admin.cloudAgentNext", + "admin.cloudAgentNext.listHealthErrorSessions query [admin] -> trpc.admin.cloudAgentNext", + "admin.cloudBillingSkus.create mutation [admin] -> trpc.admin.cloudBillingSkus", + "admin.cloudBillingSkus.disable mutation [admin] -> trpc.admin.cloudBillingSkus", + "admin.cloudBillingSkus.getUsageSummary query [admin] -> trpc.admin.cloudBillingSkus", + "admin.cloudBillingSkus.list query [admin] -> trpc.admin.cloudBillingSkus", + "admin.cloudBillingSkus.listUsageSegments query [admin] -> trpc.admin.cloudBillingSkus", + "admin.cloudBillingSkus.reconcileUsageWithCloudflare mutation [admin] -> trpc.admin.cloudBillingSkus", + "admin.cloudBillingSkus.searchUsageCharges query [admin] -> trpc.admin.cloudBillingSkus", + "admin.cloudBillingSkus.searchUsageIntervals query [admin] -> trpc.admin.cloudBillingSkus", + "admin.cloudBillingSkus.usageHealth query [admin] -> trpc.admin.cloudBillingSkus", + "admin.codeReviews.getCancellationAnalysis query [admin] -> trpc.admin.codeReviews", + "admin.codeReviews.getDailyStats query [admin] -> trpc.admin.codeReviews", + "admin.codeReviews.getErrorAnalysis query [admin] -> trpc.admin.codeReviews", + "admin.codeReviews.getErrorSessions query [admin] -> trpc.admin.codeReviews", + "admin.codeReviews.getExportData query [admin] -> trpc.admin.codeReviews", + "admin.codeReviews.getOverviewStats query [admin] -> trpc.admin.codeReviews", + "admin.codeReviews.getPerformanceStats query [admin] -> trpc.admin.codeReviews", + "admin.codeReviews.getQueueHealthStats query [admin] -> trpc.admin.codeReviews", + "admin.codeReviews.getUserSegmentation query [admin] -> trpc.admin.codeReviews", + "admin.codeReviews.getWaitTimeStats query [admin] -> trpc.admin.codeReviews", + "admin.codeReviews.searchOrganizations query [admin] -> trpc.admin.codeReviews", + "admin.codeReviews.searchUsers query [admin] -> trpc.admin.codeReviews", + "admin.contributorChampions.contributionDrillIn query [admin] -> trpc.admin.contributorChampions", + "admin.contributorChampions.enroll mutation [admin,credit_manager] -> trpc.admin.contributorChampions", + "admin.contributorChampions.enrolledList query [admin] -> trpc.admin.contributorChampions", + "admin.contributorChampions.leaderboard query [admin] -> trpc.admin.contributorChampions", + "admin.contributorChampions.manualEnroll mutation [admin,credit_manager] -> trpc.admin.contributorChampions", + "admin.contributorChampions.reviewQueue query [admin] -> trpc.admin.contributorChampions", + "admin.contributorChampions.searchKiloUsers query [admin] -> trpc.admin.contributorChampions", + "admin.contributorChampions.setSelectedTier mutation [admin] -> trpc.admin.contributorChampions", + "admin.contributorChampions.syncNow mutation [admin] -> trpc.admin.contributorChampions", + "admin.contributorChampions.upgradeTier mutation [admin,credit_manager] -> trpc.admin.contributorChampions", + "admin.creditCampaigns.create mutation [admin,credit_manager] -> trpc.admin.creditCampaigns", + "admin.creditCampaigns.get query [admin] -> trpc.admin.creditCampaigns", + "admin.creditCampaigns.getRedemptions query [admin] -> trpc.admin.creditCampaigns", + "admin.creditCampaigns.list query [admin] -> trpc.admin.creditCampaigns", + "admin.creditCampaigns.setActive mutation [admin,credit_manager] -> trpc.admin.creditCampaigns", + "admin.creditCampaigns.update mutation [admin,credit_manager] -> trpc.admin.creditCampaigns", + "admin.customLlm.copy mutation [admin] -> trpc.admin.customLlm", + "admin.customLlm.delete mutation [admin] -> trpc.admin.customLlm", + "admin.customLlm.list query [admin] -> trpc.admin.customLlm", + "admin.customLlm.upsert mutation [admin] -> trpc.admin.customLlm", + "admin.customPricing.get query [admin] -> trpc.admin.customPricing", + "admin.customPricing.set mutation [admin] -> trpc.admin.customPricing", + "admin.disputes.accept mutation [admin] -> trpc.admin.disputes", + "admin.disputes.list query [admin] -> trpc.admin.disputes", + "admin.disputes.summary query [admin] -> trpc.admin.disputes", + "admin.earlyFraudWarnings.list query [admin] -> trpc.admin.earlyFraudWarnings", + "admin.emailTesting.getPreview query [admin] -> trpc.admin.emailTesting", + "admin.emailTesting.getTemplates query [admin] -> trpc.admin.emailTesting", + "admin.emailTesting.sendTest mutation [admin] -> trpc.admin.emailTesting", + "admin.enkryptUsage.list query [admin] -> trpc.admin.enkryptUsage", + "admin.enrichmentData.upsert mutation [admin] -> trpc.admin.enrichmentData", + "admin.extendClawTrial.extendTrials mutation [admin] -> trpc.admin.extendClawTrial", + "admin.extendClawTrial.matchUsers query [admin] -> trpc.admin.extendClawTrial", + "admin.featureInterest.detail query [admin] -> trpc.admin.featureInterest", + "admin.featureInterest.list query [admin] -> trpc.admin.featureInterest", + "admin.featureInterest.timeline query [admin] -> trpc.admin.featureInterest", + "admin.gastown.bulkDeleteBeads mutation [admin] -> trpc.admin.gastown", + "admin.gastown.deleteBeadsByStatus mutation [admin] -> trpc.admin.gastown", + "admin.gastown.forceCloseBead mutation [admin] -> trpc.admin.gastown", + "admin.gastown.forceFailBead mutation [admin] -> trpc.admin.gastown", + "admin.gastown.forceRefreshCredentials mutation [admin] -> trpc.admin.gastown", + "admin.gastown.forceResetAgent mutation [admin] -> trpc.admin.gastown", + "admin.gastown.forceRestartContainer mutation [admin] -> trpc.admin.gastown", + "admin.gastown.forceRetryReview mutation [admin] -> trpc.admin.gastown", + "admin.gastown.getAgentEvents query [admin] -> trpc.admin.gastown", + "admin.gastown.getBead query [admin] -> trpc.admin.gastown", + "admin.gastown.getBeadEvents query [admin] -> trpc.admin.gastown", + "admin.gastown.getCloudflareLinks query [admin] -> trpc.admin.gastown", + "admin.gastown.getConvoyStatus query [admin] -> trpc.admin.gastown", + "admin.gastown.getTownConfig query [admin] -> trpc.admin.gastown", + "admin.gastown.getTownHealth query [admin] -> trpc.admin.gastown", + "admin.gastown.getUserRigs query [admin] -> trpc.admin.gastown", + "admin.gastown.getUserTowns query [admin] -> trpc.admin.gastown", + "admin.gastown.listAgents query [admin] -> trpc.admin.gastown", + "admin.gastown.listAuditLog query [admin] -> trpc.admin.gastown", + "admin.gastown.listBeads query [admin] -> trpc.admin.gastown", + "admin.gastown.listContainerEvents query [admin] -> trpc.admin.gastown", + "admin.gastown.listConvoys query [admin] -> trpc.admin.gastown", + "admin.gastown.listCredentialEvents query [admin] -> trpc.admin.gastown", + "admin.gastown.listDispatchAttempts query [admin] -> trpc.admin.gastown", + "admin.gastown.updateTownConfig mutation [admin] -> trpc.admin.gastown", + "admin.gatewayConfig.get query [admin] -> trpc.admin.gatewayConfig", + "admin.gatewayConfig.getOrganizationFeatures query [admin] -> trpc.admin.gatewayConfig", + "admin.gatewayConfig.previewOrganizationFeatures query [admin] -> trpc.admin.gatewayConfig", + "admin.gatewayConfig.set mutation [admin] -> trpc.admin.gatewayConfig", + "admin.gatewayConfig.setOrganizationFeatures mutation [admin] -> trpc.admin.gatewayConfig", + "admin.gatewayModelLists.get query [admin] -> trpc.admin.gatewayModelLists", + "admin.gatewayModelLists.set mutation [admin] -> trpc.admin.gatewayModelLists", + "admin.getPermissions query [admin] -> trpc.admin.getPermissions", + "admin.github.getKilocodeOpenPullRequestCounts query [admin] -> trpc.admin.github", + "admin.github.getKilocodeOpenPullRequestsSummary query [admin] -> trpc.admin.github", + "admin.github.getKilocodeRecentlyClosedExternalPRs query [admin] -> trpc.admin.github", + "admin.github.getKilocodeRecentlyMergedExternalPRs query [admin] -> trpc.admin.github", + "admin.github.lookupOrganizationInstallation mutation [admin] -> trpc.admin.github", + "admin.github.uninstallOrganizationInstallation mutation [admin] -> trpc.admin.github", + "admin.impactReferrals.investigateReferrer query [admin] -> trpc.admin.impactReferrals", + "admin.internalBotUsers.create mutation [admin] -> trpc.admin.internalBotUsers", + "admin.internalBotUsers.list query [admin] -> trpc.admin.internalBotUsers", + "admin.kiloPass.cancelAndRefundKiloPassBulk mutation [admin] -> trpc.admin.kiloPass", + "admin.kiloclawInstances.adminAuditLogs query [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.bulkChangeVersion mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.cancelDoctorViaController mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.cancelKiloCliRun mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.cancelScheduledAction mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.cancelScheduledActionTarget mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.candidateVolumes query [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.cleanupRecoveryPreviousVolume mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.clearAdminMachineSizeOverride mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.controllerVersion query [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.createFleetUpgrade mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.cycleInboundEmailAddress mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.destroy mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.destroyFlyMachine mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.destroyOrphan mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.destroyOrphanVolume mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.detectOrphans mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.devNukeAll mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.doctorViaControllerStatus query [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.extendVolume mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.fileTree query [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.findOrphanVolumes mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.forceRetryRecovery mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.gatewayRestart mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.gatewayStart mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.gatewayStatus query [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.gatewayStop mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.get query [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.getCliRunOutput query [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.getKiloCliRunStatus query [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.getScheduledAction query [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.list query [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.listAllCliRuns query [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.listKiloCliRuns query [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.listScheduledActions query [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.listUpcomingScheduledActionsForInstance query [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.machineStart mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.machineStop mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.previewFleetUpgrade query [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.readFile query [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.reassociateVolume mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.registryEntries query [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.releaseReservation mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.resizeMachine mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.restartMachine mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.restoreConfig mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.restoreVolumeSnapshot mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.runDoctor mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.runNoticeSweepNow mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.scheduleAction mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.setAdminMachineSizeOverride mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.setEarlyAccess mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.setInboundEmailEnabled mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.startDoctorViaController mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.startKiloCliRun mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.stats query [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.volumeSnapshots query [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawInstances.writeFile mutation [admin] -> trpc.admin.kiloclawInstances", + "admin.kiloclawProviders.getRollout query [admin] -> trpc.admin.kiloclawProviders", + "admin.kiloclawProviders.updateRollout mutation [admin] -> trpc.admin.kiloclawProviders", + "admin.kiloclawRegions.getRegions query [admin] -> trpc.admin.kiloclawRegions", + "admin.kiloclawRegions.updateRegions mutation [admin] -> trpc.admin.kiloclawRegions", + "admin.kiloclawVersions.bulkDisableVersions mutation [admin] -> trpc.admin.kiloclawVersions", + "admin.kiloclawVersions.getActiveRollout query [admin] -> trpc.admin.kiloclawVersions", + "admin.kiloclawVersions.getLatestTag query [admin] -> trpc.admin.kiloclawVersions", + "admin.kiloclawVersions.getUserPin query [admin] -> trpc.admin.kiloclawVersions", + "admin.kiloclawVersions.getVersionDistribution query [admin] -> trpc.admin.kiloclawVersions", + "admin.kiloclawVersions.listPins query [admin] -> trpc.admin.kiloclawVersions", + "admin.kiloclawVersions.listVersions query [admin] -> trpc.admin.kiloclawVersions", + "admin.kiloclawVersions.markLatest mutation [admin] -> trpc.admin.kiloclawVersions", + "admin.kiloclawVersions.removePin mutation [admin] -> trpc.admin.kiloclawVersions", + "admin.kiloclawVersions.searchUsers query [admin] -> trpc.admin.kiloclawVersions", + "admin.kiloclawVersions.setPin mutation [admin] -> trpc.admin.kiloclawVersions", + "admin.kiloclawVersions.setRolloutPercent mutation [admin] -> trpc.admin.kiloclawVersions", + "admin.kiloclawVersions.syncCatalog mutation [admin] -> trpc.admin.kiloclawVersions", + "admin.kiloclawVersions.updateVersionStatus mutation [admin] -> trpc.admin.kiloclawVersions", + "admin.modelEvalIngest.list query [admin] -> trpc.admin.modelEvalIngest", + "admin.modelEvalIngest.repullPromotion mutation [admin] -> trpc.admin.modelEvalIngest", + "admin.modelEvalIngest.syncNow mutation [admin] -> trpc.admin.modelEvalIngest", + "admin.modelStats.bustCache mutation [admin] -> trpc.admin.modelStats", + "admin.modelStats.create mutation [admin] -> trpc.admin.modelStats", + "admin.modelStats.list query [admin] -> trpc.admin.modelStats", + "admin.modelStats.triggerSync mutation [admin] -> trpc.admin.modelStats", + "admin.modelStats.update mutation [admin] -> trpc.admin.modelStats", + "admin.modelTraffic.get query [admin] -> trpc.admin.modelTraffic", + "admin.ossSponsorship.addExistingOrgToOss mutation [admin] -> trpc.admin.ossSponsorship", + "admin.ossSponsorship.listOssSponsorships query [admin] -> trpc.admin.ossSponsorship", + "admin.ossSponsorship.processOssCsv mutation [admin] -> trpc.admin.ossSponsorship", + "admin.ossSponsorship.searchOrganizations query [admin] -> trpc.admin.ossSponsorship", + "admin.preferredProviderOrder.get query [admin] -> trpc.admin.preferredProviderOrder", + "admin.preferredProviderOrder.set mutation [admin] -> trpc.admin.preferredProviderOrder", + "admin.requestLoggingOptIns.create mutation [admin] -> trpc.admin.requestLoggingOptIns", + "admin.requestLoggingOptIns.delete mutation [admin] -> trpc.admin.requestLoggingOptIns", + "admin.requestLoggingOptIns.list query [admin] -> trpc.admin.requestLoggingOptIns", + "admin.salesDemo.create mutation [admin] -> trpc.admin.salesDemo", + "admin.salesDemo.reset mutation [admin] -> trpc.admin.salesDemo", + "admin.securityAdvisorContent.checkCatalog.delete mutation [admin] -> trpc.admin.securityAdvisorContent", + "admin.securityAdvisorContent.checkCatalog.list query [admin] -> trpc.admin.securityAdvisorContent", + "admin.securityAdvisorContent.checkCatalog.upsert mutation [admin] -> trpc.admin.securityAdvisorContent", + "admin.securityAdvisorContent.content.delete mutation [admin] -> trpc.admin.securityAdvisorContent", + "admin.securityAdvisorContent.content.list query [admin] -> trpc.admin.securityAdvisorContent", + "admin.securityAdvisorContent.content.upsert mutation [admin] -> trpc.admin.securityAdvisorContent", + "admin.securityAdvisorContent.kiloclawCoverage.delete mutation [admin] -> trpc.admin.securityAdvisorContent", + "admin.securityAdvisorContent.kiloclawCoverage.list query [admin] -> trpc.admin.securityAdvisorContent", + "admin.securityAdvisorContent.kiloclawCoverage.upsert mutation [admin] -> trpc.admin.securityAdvisorContent", + "admin.serviceFeeRollback.executeBatch mutation [admin,superadmin] -> trpc.admin.serviceFeeRollback", + "admin.serviceFeeRollback.overview query [admin] -> trpc.admin.serviceFeeRollback", + "admin.serviceFeeRollback.reconcileStale mutation [admin,superadmin] -> trpc.admin.serviceFeeRollback", + "admin.sessionTraces.get query [admin,session_viewer] -> trpc.admin.sessionTraces", + "admin.sessionTraces.getApiConversationHistory query [admin,session_viewer] -> trpc.admin.sessionTraces", + "admin.sessionTraces.getContainerInfo query [admin,session_viewer] -> trpc.admin.sessionTraces", + "admin.sessionTraces.getContainerMetrics query [admin,session_viewer] -> trpc.admin.sessionTraces", + "admin.sessionTraces.getMessages query [admin,session_viewer] -> trpc.admin.sessionTraces", + "admin.sessionTraces.resolveCloudAgentSession query [admin,session_viewer] -> trpc.admin.sessionTraces", + "admin.syncProviders.getLastSync query [admin] -> trpc.admin.syncProviders", + "admin.syncProviders.getManualSyncEnabled query [admin] -> trpc.admin.syncProviders", + "admin.syncProviders.postTestStaleAlert mutation [admin] -> trpc.admin.syncProviders", + "admin.syncProviders.triggerDirectByokSync mutation [admin] -> trpc.admin.syncProviders", + "admin.syncProviders.triggerSync mutation [admin] -> trpc.admin.syncProviders", + "admin.userDataExports.cancelAndPurge mutation [admin] -> trpc.admin.userDataExports", + "admin.userDataExports.cancelAndRetry mutation [admin] -> trpc.admin.userDataExports", + "admin.userDataExports.detail query [admin] -> trpc.admin.userDataExports", + "admin.userDataExports.list query [admin] -> trpc.admin.userDataExports", + "admin.userDataExports.redispatch mutation [admin] -> trpc.admin.userDataExports", + "admin.userDataExports.summary query [admin] -> trpc.admin.userDataExports", + "admin.userDeletionQueue.cancel mutation [admin] -> trpc.admin.userDeletionQueue", + "admin.userDeletionQueue.deleteSubstackCredential mutation [admin] -> trpc.admin.userDeletionQueue", + "admin.userDeletionQueue.detail query [admin] -> trpc.admin.userDeletionQueue", + "admin.userDeletionQueue.list query [admin] -> trpc.admin.userDeletionQueue", + "admin.userDeletionQueue.preview mutation [admin] -> trpc.admin.userDeletionQueue", + "admin.userDeletionQueue.previewHistoricalUsers mutation [admin] -> trpc.admin.userDeletionQueue", + "admin.userDeletionQueue.replaceSubstackCredential mutation [admin] -> trpc.admin.userDeletionQueue", + "admin.userDeletionQueue.retryPreflight mutation [admin] -> trpc.admin.userDeletionQueue", + "admin.userDeletionQueue.retryTask mutation [admin] -> trpc.admin.userDeletionQueue", + "admin.userDeletionQueue.submit mutation [admin] -> trpc.admin.userDeletionQueue", + "admin.userDeletionQueue.submitHistoricalUsers mutation [admin] -> trpc.admin.userDeletionQueue", + "admin.userDeletionQueue.substackCredential query [admin] -> trpc.admin.userDeletionQueue", + "admin.userDeletionQueue.summary query [admin] -> trpc.admin.userDeletionQueue", + "admin.userDeletionQueue.testSubstackCredential mutation [admin] -> trpc.admin.userDeletionQueue", + "admin.userDeletionQueue.verifyTask mutation [admin] -> trpc.admin.userDeletionQueue", + "admin.users.DEV_ONLY_messUpBalance mutation [admin] -> trpc.admin.users", + "admin.users.addNote mutation [admin] -> trpc.admin.users", + "admin.users.cancelAndRefundKiloPass mutation [admin] -> trpc.admin.users", + "admin.users.cancelKiloClawSubscription mutation [admin] -> trpc.admin.users", + "admin.users.checkKiloPass mutation [admin] -> trpc.admin.users", + "admin.users.deleteNote mutation [admin] -> trpc.admin.users", + "admin.users.getInvoices query [admin] -> trpc.admin.users", + "admin.users.getKiloClawState query [admin] -> trpc.admin.users", + "admin.users.getKiloClawSubscriptionChangeLogs query [admin] -> trpc.admin.users", + "admin.users.getKiloPassState query [admin] -> trpc.admin.users", + "admin.users.getStytchFingerprints query [admin] -> trpc.admin.users", + "admin.users.listPlatformAdmins query [admin] -> trpc.admin.users", + "admin.users.recomputeBalances mutation [admin] -> trpc.admin.users", + "admin.users.releaseEmailAddress mutation [admin] -> trpc.admin.users", + "admin.users.resetAPIKey mutation [admin] -> trpc.admin.users", + "admin.users.resetToMagicLinkLogin mutation [admin] -> trpc.admin.users", + "admin.users.searchPlatformAdminCandidates query [admin,superadmin] -> trpc.admin.users", + "admin.users.setAdminPermissions mutation [admin,superadmin] -> trpc.admin.users", + "admin.users.setPersonalAccountDisabled mutation [admin] -> trpc.admin.users", + "admin.users.setPlatformAdminAccess mutation [admin,superadmin] -> trpc.admin.users", + "admin.users.signOutBrowserSessions mutation [admin] -> trpc.admin.users", + "admin.users.updateBlockStatus mutation [admin] -> trpc.admin.users", + "admin.users.updateKiloClawTrialEndAt mutation [admin] -> trpc.admin.users", + "admin.vercelModelIdOverrides.list query [admin] -> trpc.admin.vercelModelIdOverrides", + "admin.webhookTriggers.get query [admin] -> trpc.admin.webhookTriggers", + "admin.webhookTriggers.getRequestLogs query [admin,session_viewer] -> trpc.admin.webhookTriggers", + "admin.webhookTriggers.list query [admin] -> trpc.admin.webhookTriggers", + "admin.webhookTriggers.listRequests query [admin,session_viewer] -> trpc.admin.webhookTriggers", + "codeIndexing.admin.getClusterStatus query [admin] -> trpc.codeIndexing.admin", + "codeIndexing.admin.getSummaryStats query [admin] -> trpc.codeIndexing.admin", + "codeIndexing.admin.getUserSummaryStats query [admin] -> trpc.codeIndexing.admin", + "codingPlans.adminAvailabilityIntentCounts query [admin] -> trpc.codingPlans.adminAvailabilityIntentCounts", + "codingPlans.adminCancelSubscription mutation [admin] -> trpc.codingPlans.adminCancelSubscription", + "codingPlans.adminExtendSubscriptionPeriod mutation [admin] -> trpc.codingPlans.adminExtendSubscriptionPeriod", + "codingPlans.adminInsights query [admin] -> trpc.codingPlans.adminInsights", + "codingPlans.adminKeyInventory query [admin] -> trpc.codingPlans.adminKeyInventory", + "codingPlans.adminListSubscriptions query [admin] -> trpc.codingPlans.adminListSubscriptions", + "codingPlans.adminMarkRevocationComplete mutation [admin] -> trpc.codingPlans.adminMarkRevocationComplete", + "codingPlans.adminMarkRevocationFailed mutation [admin] -> trpc.codingPlans.adminMarkRevocationFailed", + "codingPlans.adminReassignSubscriptionCredential mutation [admin] -> trpc.codingPlans.adminReassignSubscriptionCredential", + "codingPlans.adminReduceInventory mutation [admin] -> trpc.codingPlans.adminReduceInventory", + "codingPlans.adminReplaceInventoryCredential mutation [admin] -> trpc.codingPlans.adminReplaceInventoryCredential", + "codingPlans.adminReplaceRevocationCredential mutation [admin] -> trpc.codingPlans.adminReplaceRevocationCredential", + "codingPlans.adminRequeueRevocation mutation [admin] -> trpc.codingPlans.adminRequeueRevocation", + "codingPlans.adminRevocationQueue query [admin] -> trpc.codingPlans.adminRevocationQueue", + "codingPlans.adminSubscriptionOverview query [admin] -> trpc.codingPlans.adminSubscriptionOverview", + "codingPlans.adminTerminateSubscription mutation [admin] -> trpc.codingPlans.adminTerminateSubscription", + "codingPlans.adminUploadKeys mutation [admin] -> trpc.codingPlans.adminUploadKeys", + "debug.badInputError query [admin] -> trpc.debug.badInputError", + "debug.badInputObjectError query [admin] -> trpc.debug.badInputObjectError", + "debug.handledTrpcError query [admin] -> trpc.debug.handledTrpcError", + "debug.unhandledError query [admin] -> trpc.debug.unhandledError", + "organizations.admin.addMember mutation [admin] -> trpc.organizations.admin", + "organizations.admin.clearKiloPassPaymentReview mutation [admin] -> trpc.organizations.admin", + "organizations.admin.create mutation [admin] -> trpc.organizations.admin", + "organizations.admin.creditTransactions query [admin] -> trpc.organizations.admin", + "organizations.admin.delete mutation [admin] -> trpc.organizations.admin", + "organizations.admin.getDetails query [admin] -> trpc.organizations.admin", + "organizations.admin.getHierarchy query [admin] -> trpc.organizations.admin", + "organizations.admin.getKiloPassSummary query [admin] -> trpc.organizations.admin", + "organizations.admin.getMetrics query [admin] -> trpc.organizations.admin", + "organizations.admin.getServiceFeeExemption query [admin] -> trpc.organizations.admin", + "organizations.admin.grantCredit mutation [admin,credit_manager] -> trpc.organizations.admin", + "organizations.admin.list query [admin] -> trpc.organizations.admin", + "organizations.admin.nextCreditExpiration query [admin] -> trpc.organizations.admin", + "organizations.admin.nullifyCredits mutation [admin,credit_manager] -> trpc.organizations.admin", + "organizations.admin.search query [admin] -> trpc.organizations.admin", + "organizations.admin.searchParentMembers query [admin] -> trpc.organizations.admin", + "organizations.admin.setParent mutation [admin] -> trpc.organizations.admin", + "organizations.admin.setServiceFeeExemption mutation [admin] -> trpc.organizations.admin", + "organizations.admin.updateCreatedBy mutation [admin] -> trpc.organizations.admin", + "organizations.admin.updateFreeTrialEndAt mutation [admin] -> trpc.organizations.admin", + "organizations.admin.updateKiloEncrypt mutation [admin] -> trpc.organizations.admin", + "organizations.admin.updateSuppressTrialMessaging mutation [admin] -> trpc.organizations.admin", + "organizations.seatPurchases query [admin] -> trpc.organizations.seatPurchases", + "organizations.sso.clearSsoDomain mutation [admin] -> trpc.organizations.sso", + "organizations.sso.createConfig mutation [admin] -> trpc.organizations.sso", + "organizations.sso.deleteConfig mutation [admin] -> trpc.organizations.sso", + "organizations.sso.generateAdminPortalLink mutation [admin] -> trpc.organizations.sso", + "organizations.sso.updateSsoDomain mutation [admin] -> trpc.organizations.sso", + "organizations.updatePlan mutation [admin] -> trpc.organizations.updatePlan", + "organizations.updateSeatsRequired mutation [admin] -> trpc.organizations.updateSeatsRequired", + "test.adminHello query [admin] -> trpc.test.adminHello", +] +`; diff --git a/apps/web/src/routers/privileged-inventory.test.ts b/apps/web/src/routers/privileged-inventory.test.ts new file mode 100644 index 00000000000..f5d0ff2a059 --- /dev/null +++ b/apps/web/src/routers/privileged-inventory.test.ts @@ -0,0 +1,517 @@ +import { describe, expect, test } from '@jest/globals'; +import fs from 'node:fs'; +import path from 'node:path'; +import { rootRouter } from './root-router'; +import { + ADMIN_AREA_POLICIES, + findPrivilegedInventoryEntry, + internalElevationSites, + privilegedGuardTiers, +} from '@kilocode/web-shared/lib/admin/privileged-inventory'; + +const REPO_ROOT = path.resolve(__dirname, '../../../..'); +const APP_ROOT = path.join(REPO_ROOT, 'apps/web/src/app'); + +const BROAD_WILDCARDS = new Set(['organizations.*', 'codeIndexing.*']); + +type ProcedureDef = { + _def?: { + type?: string; + middlewares?: readonly unknown[]; + }; +}; + +type DiscoveredRoute = { + file: string; + route: string; + methods: string[]; + explicitAdminGuard: boolean; + organizationContext: boolean; +}; + +function walkFiles(root: string): string[] { + return fs.readdirSync(root, { recursive: true, withFileTypes: true }).flatMap(entry => { + const entryPath = path.join(entry.parentPath, entry.name); + return entry.isDirectory() ? [] : [entryPath]; + }); +} + +function stripCommentsAndStrings(source: string): string { + let output = ''; + let state: 'code' | 'line' | 'block' | 'single' | 'double' | 'template' = 'code'; + let index = 0; + + while (index < source.length) { + const char = source[index]; + const next = source[index + 1]; + + if (state === 'code') { + if (char === '/' && next === '/') { + state = 'line'; + index += 2; + continue; + } + if (char === '/' && next === '*') { + state = 'block'; + index += 2; + continue; + } + if (char === "'" || char === '"' || char === '`') { + state = char === "'" ? 'single' : char === '"' ? 'double' : 'template'; + output += ' '; + index += 1; + continue; + } + output += char; + index += 1; + continue; + } + + if (state === 'line') { + if (char === '\n') { + state = 'code'; + output += '\n'; + } + index += 1; + continue; + } + + if (state === 'block') { + if (char === '*' && next === '/') { + state = 'code'; + index += 2; + } else { + index += 1; + } + continue; + } + + const quote = state === 'single' ? "'" : state === 'double' ? '"' : '`'; + if (char === '\\') { + index += 2; + continue; + } + if (char === quote) { + state = 'code'; + output += ' '; + } + index += 1; + } + + return output; +} + +function countIsAdminReads(source: string): number { + return (stripCommentsAndStrings(source).match(/\.\s*is_admin\b/g) ?? []).length; +} + +function routePathFromAppFile(file: string): string { + const relative = path.relative(APP_ROOT, file); + const segments = relative.split(path.sep); + segments.pop(); + const withoutGroups = segments.filter(segment => !/^\(.+\)$/.test(segment)); + return `/${withoutGroups.join('/')}`; +} + +function exportedMethods(source: string): string[] { + const methods = new Set(); + const exportLines = source.split('\n').filter(line => line.trimStart().startsWith('export ')); + for (const line of exportLines) { + for (const method of ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']) { + if (new RegExp(`\\b${method}\\b`).test(line)) methods.add(method); + } + } + return [...methods]; +} + +function accessKindsFor(methods: string[]): ('read' | 'write')[] { + return methods.length === 0 + ? ['read', 'write'] + : [ + ...new Set( + methods.map(method => (method === 'GET' || method === 'HEAD' ? 'read' : 'write')) + ), + ]; +} + +function discoverRESTRoutes(): DiscoveredRoute[] { + return walkFiles(APP_ROOT) + .filter(file => path.basename(file) === 'route.ts') + .map(file => { + const source = fs.readFileSync(file, 'utf8'); + return { + file, + route: routePathFromAppFile(file), + methods: exportedMethods(source), + explicitAdminGuard: source.includes('adminOnly: true'), + organizationContext: + source.includes('getAuthorizedOrgContext(') || + source.includes('ensureOrganizationAccess(') || + source.includes('ensureOrganizationAccessAndFetchOrg('), + }; + }); +} + +function isPrivilegedRoute(discovered: DiscoveredRoute): boolean { + return ( + discovered.route.startsWith('/admin/') || + discovered.explicitAdminGuard || + discovered.organizationContext + ); +} + +function discoverServerComponents(): { file: string; route: string }[] { + return walkFiles(APP_ROOT) + .filter(file => /(page|layout)\.tsx$/.test(path.basename(file))) + .map(file => ({ file, route: routePathFromAppFile(file) })) + .filter(({ file, route }) => { + const source = fs.readFileSync(file, 'utf8'); + return ( + (route.startsWith('/admin') || route.startsWith('/organizations')) && + (source.includes('adminOnly: true') || source.includes('getAuthorizedOrgContext(')) + ); + }); +} + +function workerAdminProcedureNames(worker: 'gastown' | 'wasteland'): string[] { + const file = path.join(REPO_ROOT, `services/${worker}/src/trpc/router.ts`); + const source = fs.readFileSync(file, 'utf8'); + return [...source.matchAll(/^\s*([A-Za-z0-9_]+):\s*adminProcedure\b/gm)].map( + match => match[1] as string + ); +} + +function workerAdminRoute(worker: 'gastown' | 'wasteland', name: string): string { + return `${worker}.${name}`; +} + +const procedures = rootRouter._def.procedures as Record; +const discoveredProcedures = Object.entries(procedures).map(([procedurePath, def]) => ({ + path: procedurePath, + type: def?._def?.type ?? 'query', + tiers: privilegedGuardTiers(def?._def?.middlewares ?? []), +})); +const adminProcedures = discoveredProcedures.filter(procedure => + procedure.path.startsWith('admin.') +); +const privilegedProcedures = discoveredProcedures.filter( + procedure => procedure.tiers.length > 0 || procedure.path.startsWith('admin.') +); + +describe('privileged inventory covers discovered tRPC procedures', () => { + test('every mounted admin area has a checked-in policy', () => { + const areas = new Set( + adminProcedures + .filter(procedure => procedure.path.split('.').length > 2) + .map(procedure => procedure.path.split('.')[1] as string) + ); + const missing = [...areas].filter(area => !(area in ADMIN_AREA_POLICIES)); + expect(missing).toEqual([]); + }); + + test('every admin procedure carries the admin guard and a read/write entry', () => { + const missing: string[] = []; + const unguarded: string[] = []; + + for (const procedure of adminProcedures) { + const access = procedure.type === 'mutation' ? 'write' : 'read'; + const entry = findPrivilegedInventoryEntry({ + surface: 'trpc', + route: procedure.path, + access, + }); + if (!entry) missing.push(procedure.path); + if (!procedure.tiers.includes('admin')) unguarded.push(procedure.path); + } + + expect(missing).toEqual([]); + expect(unguarded).toEqual([]); + }); + + test('every guarded procedure outside admin.* has an explicit entry', () => { + const problems: string[] = []; + + for (const procedure of privilegedProcedures) { + const access = procedure.type === 'mutation' ? 'write' : 'read'; + const entry = findPrivilegedInventoryEntry({ + surface: 'trpc', + route: procedure.path, + access, + }); + if (!entry) { + problems.push(`${procedure.path} (no entry)`); + continue; + } + if (!procedure.path.startsWith('admin.') && BROAD_WILDCARDS.has(entry.route)) { + problems.push(`${procedure.path} (swallowed by ${entry.route})`); + } + } + + expect(problems).toEqual([]); + }); + + test('specialized guard middlewares are recognized on real procedures', () => { + const tiers = new Set(privilegedProcedures.flatMap(procedure => procedure.tiers)); + + expect(tiers).toEqual( + new Set([ + 'admin', + 'superadmin', + 'credit_manager', + 'session_viewer', + 'api_request_log_exporter', + ]) + ); + }); + + test('the guarded procedure list is reviewed as a snapshot', () => { + const covered = privilegedProcedures + .map(procedure => { + const access = procedure.type === 'mutation' ? 'write' : 'read'; + const entry = findPrivilegedInventoryEntry({ + surface: 'trpc', + route: procedure.path, + access, + }); + return `${procedure.path} ${procedure.type} [${[...procedure.tiers].sort().join(',')}] -> ${entry?.id ?? 'NONE'}`; + }) + .sort(); + + expect(covered).toMatchSnapshot('guarded-trpc-procedures'); + }); + + test('known non-admin elevation procedures are classified', () => { + const routes = [ + 'cliSessionsV2.list', + 'cliSessionsV2.search', + 'codeIndexing.search', + 'securityAgent.getAuditReport', + 'organizations.securityAgent.getAuditReport', + ]; + + for (const route of routes) { + expect( + findPrivilegedInventoryEntry({ surface: 'trpc', route, access: 'read' }) + ).toBeDefined(); + } + }); +}); + +describe('privileged inventory covers discovered route handlers', () => { + test('every /admin/api route is classified for each exported method', () => { + const problems: string[] = []; + + for (const discovered of discoverRESTRoutes()) { + if (!discovered.route.startsWith('/admin/api/')) continue; + for (const access of accessKindsFor(discovered.methods)) { + if ( + !findPrivilegedInventoryEntry({ + surface: 'rest', + route: discovered.route, + access, + }) + ) { + problems.push(`${discovered.route} ${access}`); + } + } + } + + expect(problems).toEqual([]); + }); + + test('every route handler with an explicit admin or org-context guard is classified', () => { + const problems: string[] = []; + + for (const discovered of discoverRESTRoutes()) { + if (!isPrivilegedRoute(discovered)) continue; + for (const access of accessKindsFor(discovered.methods)) { + if ( + !findPrivilegedInventoryEntry({ + surface: 'rest', + route: discovered.route, + access, + }) + ) { + problems.push( + `${discovered.route} ${access} (${path.relative(REPO_ROOT, discovered.file)})` + ); + } + } + } + + expect(problems).toEqual([]); + }); + + test('the privileged route-to-entry mapping is reviewed as a snapshot', () => { + const mapping = discoverRESTRoutes() + .filter(isPrivilegedRoute) + .flatMap(discovered => + accessKindsFor(discovered.methods).map(access => { + const entry = findPrivilegedInventoryEntry({ + surface: 'rest', + route: discovered.route, + access, + }); + return `${discovered.route} ${access} -> ${entry?.id ?? 'NONE'}`; + }) + ) + .sort(); + + expect(mapping).toMatchSnapshot('privileged-rest-routes'); + }); + + test('wildcards stop at path-segment boundaries', () => { + expect( + findPrivilegedInventoryEntry({ surface: 'rest', route: '/organizationsX', access: 'read' }) + ).toBeUndefined(); + expect( + findPrivilegedInventoryEntry({ + surface: 'rest', + route: '/api/organizations/00000000-0000-0000-0000-000000000000/user-tokens', + access: 'write', + })?.id + ).toBe('rest.organizationTokens'); + }); + + test('admin and organization server components are classified', () => { + const missing: string[] = []; + + for (const component of discoverServerComponents()) { + if ( + !findPrivilegedInventoryEntry({ + surface: 'server-component', + route: component.route, + access: 'read', + }) + ) { + missing.push(component.route); + } + } + + expect(missing).toEqual([]); + }); +}); + +describe('privileged inventory covers Worker admin gates', () => { + test.each(['gastown', 'wasteland'] as const)('%s admin procedures are classified', worker => { + const names = workerAdminProcedureNames(worker); + expect(names.length).toBeGreaterThan(0); + + for (const name of names) { + expect( + findPrivilegedInventoryEntry({ surface: 'worker', route: workerAdminRoute(worker, name) }) + ).toBeDefined(); + } + }); + + test('the Worker admin procedure list is reviewed as a snapshot', () => { + const mapping = (['gastown', 'wasteland'] as const) + .flatMap(worker => + workerAdminProcedureNames(worker).map(name => { + const route = workerAdminRoute(worker, name); + const entry = findPrivilegedInventoryEntry({ surface: 'worker', route }); + return `${route} -> ${entry?.id ?? 'NONE'}`; + }) + ) + .sort(); + + expect(mapping).toMatchSnapshot('worker-admin-procedures'); + }); + + test('the discovered token-mint entry points are classified', () => { + const tokenRoutes = discoverRESTRoutes() + .filter(route => /^\/api\/(gastown|wasteland)\/token$/.test(route.route)) + .map(route => route.route) + .sort(); + + expect(tokenRoutes).toEqual(['/api/gastown/token', '/api/wasteland/token']); + for (const route of tokenRoutes) { + expect( + findPrivilegedInventoryEntry({ surface: 'rest', route, access: 'write' }) + ).toBeDefined(); + } + }); +}); + +describe('privileged inventory covers branch-level admin reads', () => { + const roots = [ + 'packages/web-shared/src', + 'apps/web/src/lib', + 'apps/web/src/routers', + 'apps/web/src/app', + 'apps/web/src/components', + ]; + const excluded = new Set([ + 'packages/web-shared/src/lib/admin/admin-access-log.ts', + 'packages/web-shared/src/lib/admin/privileged-access.ts', + 'packages/web-shared/src/lib/admin/privileged-inventory.ts', + ]); + + function serverFiles(): string[] { + const files = new Set(); + for (const root of roots) { + for (const file of walkFiles(path.join(REPO_ROOT, root))) { + if (file.endsWith('.test.ts') || file.endsWith('.test.tsx')) continue; + if (file.startsWith(path.join(REPO_ROOT, 'packages/web-shared/src/tests/'))) continue; + if (file.endsWith('.ts')) { + const relative = path.relative(REPO_ROOT, file); + if (!excluded.has(relative)) files.add(relative); + } else if ( + file.endsWith('.tsx') && + !fs.readFileSync(file, 'utf8').includes("'use client'") + ) { + files.add(path.relative(REPO_ROOT, file)); + } + } + } + return [...files].sort(); + } + + function isAdminReadsIn(relativeFile: string): number { + return countIsAdminReads(fs.readFileSync(path.join(REPO_ROOT, relativeFile), 'utf8')); + } + + test('does not let a comment marker inside a string hide a real read', () => { + const source = [ + 'const url = "https://github.com/example/repo"; const flag = user.is_admin;', + 'const quoted = "ctx.user.is_admin";', + '// user.is_admin in a line comment', + '/* tenant.is_admin in a block comment */', + 'const record = { is_admin: false };', + 'const value = ctx.user.is_admin;', + ].join('\n'); + + expect(countIsAdminReads(source)).toBe(2); + }); + + test('every is_admin read is classified with a reviewed occurrence count', () => { + const counts = new Map(internalElevationSites.map(site => [site.file, site.occurrences])); + const problems: string[] = []; + + for (const file of serverFiles()) { + const actual = isAdminReadsIn(file); + if (actual === 0) continue; + const expected = counts.get(file); + if (expected === undefined) { + problems.push(`${file}: unclassified (${actual} occurrences)`); + } else if (expected !== actual) { + problems.push(`${file}: expected ${expected} occurrences, found ${actual}`); + } + } + + expect(problems).toEqual([]); + }); + + test('every checked-in branch-level site still exists at its reviewed count', () => { + const stale: string[] = []; + + for (const site of internalElevationSites) { + const absolute = path.join(REPO_ROOT, site.file); + if (!fs.existsSync(absolute) || isAdminReadsIn(site.file) !== site.occurrences) { + stale.push(site.file); + } + } + + expect(stale).toEqual([]); + }); +}); diff --git a/apps/web/src/routers/trpc-provenance.test.ts b/apps/web/src/routers/trpc-provenance.test.ts new file mode 100644 index 00000000000..47cc80bdd65 --- /dev/null +++ b/apps/web/src/routers/trpc-provenance.test.ts @@ -0,0 +1,139 @@ +import { beforeAll, describe, expect, jest, test } from '@jest/globals'; +import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; +import { + baseProcedure, + createTRPCRouter, + createTRPCContext, +} from '@kilocode/web-shared/lib/trpc/init'; +import { generateApiToken } from '@kilocode/web-shared/lib/tokens'; +import { insertTestUser } from '@kilocode/web-shared/tests/helpers/user.helper'; +import type { User } from '@kilocode/db/schema'; + +const mockGetServerSession = jest.fn<(options: unknown) => Promise>(); +jest.mock('next-auth', () => ({ + __esModule: true, + getServerSession: (options: unknown) => mockGetServerSession(options), +})); + +const provenanceRouter = createTRPCRouter({ + provenance: baseProcedure.query(({ ctx }) => ({ + authViaToken: ctx.authViaToken, + credential: ctx.credential, + tokenSource: ctx.tokenSource, + })), + provenanceMutation: baseProcedure.mutation(({ ctx }) => ({ + authViaToken: ctx.authViaToken, + credential: ctx.credential, + tokenSource: ctx.tokenSource, + })), +}); + +let user: User; +let token: string; + +beforeAll(async () => { + user = await insertTestUser({ + google_user_email: `trpc-provenance-${crypto.randomUUID()}@example.com`, + api_token_pepper: `trpc-provenance-pepper-${crypto.randomUUID()}`, + web_session_pepper: 'current-web-session-pepper', + }); + token = generateApiToken(user); +}); + +function callProvenance(request: Request) { + return fetchRequestHandler({ + endpoint: '/api/trpc', + req: request, + router: provenanceRouter, + createContext: createTRPCContext, + }); +} + +describe('tRPC credential provenance', () => { + test('a GET query with Authorization is classified as bearer, never as session', async () => { + const url = new URL('http://localhost/api/trpc/provenance'); + url.searchParams.set('input', JSON.stringify({})); + + const response = await callProvenance( + new Request(url, { + method: 'GET', + headers: { authorization: `Bearer ${token}` }, + }) + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as { result: { data: unknown } }; + expect(body.result.data).toMatchObject({ + authViaToken: true, + credential: 'human_bearer', + }); + }); + + test('a batched GET request is classified as bearer for every call', async () => { + const url = new URL('http://localhost/api/trpc/provenance,provenance'); + url.searchParams.set('batch', '1'); + url.searchParams.set('input', JSON.stringify({ 0: {}, 1: {} })); + + const response = await callProvenance( + new Request(url, { + method: 'GET', + headers: { authorization: `Bearer ${token}` }, + }) + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as Array<{ result: { data: unknown } }>; + expect(body).toHaveLength(2); + for (const call of body) { + expect(call.result.data).toMatchObject({ + authViaToken: true, + credential: 'human_bearer', + }); + } + }); + + test('a batched POST request is classified as bearer for every call', async () => { + const response = await callProvenance( + new Request('http://localhost/api/trpc/provenanceMutation,provenanceMutation?batch=1', { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ 0: {}, 1: {} }), + }) + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as Array<{ result: { data: unknown } }>; + expect(body).toHaveLength(2); + for (const call of body) { + expect(call.result.data).toMatchObject({ + authViaToken: true, + credential: 'human_bearer', + }); + } + }); + + test('an empty Authorization header never falls back to a valid session', async () => { + const url = new URL('http://localhost/api/trpc/provenance'); + url.searchParams.set('input', JSON.stringify({})); + mockGetServerSession.mockResolvedValue({ + kiloUserId: user.id, + webSessionPepper: 'current-web-session-pepper', + isNewUser: false, + }); + + const response = await callProvenance( + new Request(url, { + method: 'GET', + headers: { + authorization: '', + cookie: 'next-auth.session-token=valid-session', + }, + }) + ); + + expect(response.status).toBe(401); + }); +}); diff --git a/packages/web-shared/src/lib/admin/admin-access-log.test.ts b/packages/web-shared/src/lib/admin/admin-access-log.test.ts index c8723f7ce8d..2b256e144fc 100644 --- a/packages/web-shared/src/lib/admin/admin-access-log.test.ts +++ b/packages/web-shared/src/lib/admin/admin-access-log.test.ts @@ -3,14 +3,17 @@ import { authViaTokenFromHeaders, clientIpFromHeaders, elevateViaKiloAdmin, + emitPrivilegedAccessEvent, organizationTarget, organizationsTarget, recordKiloAdminElevation, recordKiloAdminElevationForRequest, + methodFromHeaders, routeFromHeaders, serviceTarget, setAdminAccessSinkForTest, userTarget, + userAgentProductFromHeaders, UNSCOPED_TARGET, type AdminAccessEvent, type AdminAuditContext, @@ -54,10 +57,8 @@ describe('authViaTokenFromHeaders', () => { expect(authViaTokenFromHeaders(new Headers())).toBe(false); }); - test('is false for an empty Authorization header (matches the session auth branch)', () => { - // An empty header is non-null but falsy; getUserFromAuth treats it as the - // session path, so the discriminator must agree. - expect(authViaTokenFromHeaders(new Headers({ Authorization: '' }))).toBe(false); + test('is true for an empty Authorization header (the header still selects the bearer branch)', () => { + expect(authViaTokenFromHeaders(new Headers({ Authorization: '' }))).toBe(true); }); }); @@ -81,6 +82,31 @@ describe('routeFromHeaders', () => { }); }); +describe('methodFromHeaders', () => { + test('reads the method forwarded by proxy.ts', () => { + expect(methodFromHeaders(new Headers({ 'x-http-method': 'post' }))).toBe('POST'); + }); + + test('returns null when the proxy did not forward a method', () => { + expect(methodFromHeaders(new Headers())).toBeNull(); + expect(methodFromHeaders(new Headers({ 'x-http-method': '' }))).toBeNull(); + }); +}); + +describe('userAgentProductFromHeaders', () => { + test('keeps only the first product token', () => { + expect( + userAgentProductFromHeaders( + new Headers({ 'user-agent': 'kilo-cli/1.2.3 (darwin; arm64) another/1.0' }) + ) + ).toBe('kilo-cli/1.2.3'); + }); + + test('returns null without a User-Agent', () => { + expect(userAgentProductFromHeaders(new Headers())).toBeNull(); + }); +}); + describe('target builders', () => { test('organizationTarget/userTarget/serviceTarget use a : reference', () => { expect(organizationTarget('org-1')).toBe('organization:org-1'); @@ -144,6 +170,11 @@ describe('kilo admin elevation telemetry', () => { adminTier: 'platform_admin', authVia: 'session', tokenSource: null, + credential: 'session', + membershipRole: null, + wouldPassWithoutAdmin: null, + wouldBlock: false, + userAgentProduct: null, route: 'organizations.getSettings', method: 'query', ip: '203.0.113.7', @@ -257,3 +288,123 @@ describe('kilo admin elevation telemetry', () => { }); }); }); + +describe('privileged access telemetry', () => { + let events: AdminAccessEvent[]; + + beforeEach(() => { + events = []; + setAdminAccessSinkForTest(event => events.push(event)); + }); + + afterEach(() => { + setAdminAccessSinkForTest(null); + }); + + test('emits the bearer discriminator, action class, and outcome without request secrets', () => { + emitPrivilegedAccessEvent({ + surface: 'rest', + user: defineTestUser({ is_admin: true }), + credential: 'human_bearer', + outcome: 'succeeded', + actionClass: 'sensitive_write', + actionId: 'rest.admin.users', + target: 'user:user-1', + tokenSource: null, + route: '/admin/api/users', + method: null, + ip: '203.0.113.7', + }); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + event: 'admin_access', + kind: 'privileged_access', + credential: 'human_bearer', + outcome: 'succeeded', + actionClass: 'sensitive_write', + actionId: 'rest.admin.users', + target: 'user:user-1', + authVia: 'token', + route: '/admin/api/users', + ip: '203.0.113.7', + }); + expect(JSON.stringify(events[0])).not.toMatch(/authorization|cookie|bearer /i); + }); + + test('records a refused credential even when no user could be resolved', () => { + emitPrivilegedAccessEvent({ + surface: 'rest', + credential: 'human_bearer', + outcome: 'denied', + actionClass: 'support_read', + tokenSource: null, + route: '/admin/api/users', + method: null, + ip: null, + }); + + expect(events[0]).toMatchObject({ + kind: 'privileged_access', + kiloUserId: null, + email: null, + adminTier: null, + outcome: 'denied', + }); + }); + + test('records real membership and the planned-policy wouldBlock signal', async () => { + await recordKiloAdminElevation( + { + user: defineTestUser({ is_admin: true }), + authViaToken: true, + tokenSource: 'cloud-agent', + trpcPath: 'organizations.getSettings', + trpcType: 'query', + }, + { + reason: 'organization_access', + target: organizationTarget('org-1'), + membershipRole: 'member', + wouldPassWithoutAdmin: false, + } + ); + + expect(events[0]).toMatchObject({ + kind: 'kilo_admin_elevation', + credential: 'delegated_workload', + membershipRole: 'member', + wouldPassWithoutAdmin: false, + wouldBlock: true, + }); + expect(events[1]).toMatchObject({ kind: 'privileged_access', wouldBlock: true }); + }); + + test('derives delegated workload and service assertion credentials separately', () => { + emitPrivilegedAccessEvent({ + surface: 'trpc', + credential: 'delegated_workload', + outcome: 'succeeded', + actionClass: 'support_read', + tokenSource: 'cloud-agent', + route: 'organizations.getSettings', + method: 'query', + ip: null, + }); + emitPrivilegedAccessEvent({ + surface: 'rest', + credential: 'service_assertion', + outcome: 'succeeded', + actionClass: 'platform_read', + tokenSource: null, + route: '/admin/api/users', + method: null, + ip: null, + }); + + expect(events.map(event => event.kind === 'privileged_access' && event.credential)).toEqual([ + 'delegated_workload', + 'service_assertion', + ]); + }); +}); diff --git a/packages/web-shared/src/lib/admin/admin-access-log.ts b/packages/web-shared/src/lib/admin/admin-access-log.ts index 791a137c91e..05584188705 100644 --- a/packages/web-shared/src/lib/admin/admin-access-log.ts +++ b/packages/web-shared/src/lib/admin/admin-access-log.ts @@ -3,6 +3,11 @@ import type { User } from '@kilocode/db/schema'; import { captureException } from '@sentry/nextjs'; import { headers } from 'next/headers'; import { logExceptInTest } from '@kilocode/web-shared/lib/utils.server'; +import type { + PrivilegedActionClass, + PrivilegedCredentialKind, +} from '@kilocode/web-shared/lib/admin/privileged-access'; +import { deriveCredentialKind } from '@kilocode/web-shared/lib/admin/privileged-access'; /** * Per-request, identity-attributed audit telemetry for Kilocode admin access. @@ -21,7 +26,7 @@ import { logExceptInTest } from '@kilocode/web-shared/lib/utils.server'; * - `"server-component"` — a React server component render (a page, a layout, * or a server action), which has request headers but no tRPC context. * - * `kind` — what produced the event (two values): + * `kind` — what produced the event (three values): * - `"admin_guard"` — an explicit admin gate was satisfied. Emitted at the two * choke points that protect the `/admin` console: `getUserFromAuth` with * `adminOnly: true`, and the `adminProcedure` middleware. @@ -30,6 +35,9 @@ import { logExceptInTest } from '@kilocode/web-shared/lib/utils.server'; * (e.g. reading any organization, querying sessions across all orgs). These * paths pass through neither admin gate, so they emit here instead. Carries * a `reason` and a `target`. + * - `"privileged_access"` — observe-only measurement of a bearer credential + * exercising a privileged action. Carries `credential`, `outcome`, + * `actionClass`, and `wouldBlock`. * * Coverage note: `kind:"admin_guard"` alone covers only the `/admin` console * surface. Kilo's highest-value admin capability — an employee reading an @@ -69,10 +77,12 @@ export type KiloAdminElevationReason = /** Security Agent audit report assembled with Kilo-admin visibility. */ | 'security_agent_audit_report' /** A token carrying `isAdmin` was minted for a downstream service. */ - | 'service_token_mint'; + | 'service_token_mint' + /** A delegated resource token carrying `isAdmin` was minted for a Worker. */ + | 'delegated_resource_token_mint'; /** Fields every `admin_access` event carries, whatever produced it. */ -type AdminAccessAttribution = { +export type AdminAccessAttribution = { event: 'admin_access'; surface: AdminAccessSurface; kiloUserId: string; @@ -86,6 +96,26 @@ type AdminAccessAttribution = { authVia: 'token' | 'session'; /** Set only for tokens that carry a source (e.g. `cloud-agent`); else null. */ tokenSource: string | null; + /** + * How the credential was obtained, not just how it arrived. `authVia` still + * separates bearer from session; this separates a personal key from a bot, a + * delegated workload, and a service assertion. + */ + credential: PrivilegedCredentialKind; + /** The actor's real role in the target organization, when one was measured. */ + membershipRole: string | null; + /** + * Whether the actor's own membership would have authorized the action without + * the `is_admin` escape hatch. Null when the site cannot measure it. + */ + wouldPassWithoutAdmin: boolean | null; + /** + * Observe-only sizing: a bearer credential on a privileged action would be + * blocked once session-only enforcement is applied. + */ + wouldBlock: boolean; + /** First product token of the User-Agent only, never the full string. */ + userAgentProduct: string | null; /** * Best-effort route identifier. For tRPC this is the exact procedure path; * for REST and server components it is the concrete pathname (`x-pathname`) @@ -95,14 +125,28 @@ type AdminAccessAttribution = { route: string | null; /** * The tRPC operation type (`query`, `mutation`) on `surface:"trpc"`; the HTTP - * method on `surface:"rest"`. Null when no reliable value exists, which is - * today every REST and server-component path: neither carries a trustworthy - * method, so nothing is fabricated. + * method on `surface:"rest"`, forwarded by `proxy.ts` as `x-http-method`. + * Null when no reliable value exists (for example a unit test that invokes a + * shared helper without the proxy). */ method: string | null; ip: string | null; }; +/** + * Privileged-access attribution. Identity is nullable because a bearer request + * can be refused before any user is resolved; the credential, route, and + * outcome are still worth alerting on. + */ +export type PrivilegedAccessAttribution = Omit< + AdminAccessAttribution, + 'kiloUserId' | 'email' | 'adminTier' +> & { + kiloUserId: string | null; + email: string | null; + adminTier: 'super_admin' | 'platform_admin' | null; +}; + /** * Discriminated on `kind`, so the documented correlation is enforced by the * compiler: a guard event cannot carry elevation attribution, and an elevation @@ -124,6 +168,19 @@ export type AdminAccessEvent = * when the elevation is not scoped to any single resource. */ target: string; + }) + | (PrivilegedAccessAttribution & { + /** + * Measurement for privileged access. Emitted for bearer credentials only, + * so a stolen API key is visible even when the request is denied. Session + * traffic stays on `admin_guard` / `kilo_admin_elevation`. + */ + kind: 'privileged_access'; + credential: PrivilegedCredentialKind; + outcome: 'succeeded' | 'denied' | 'failed'; + actionClass: PrivilegedActionClass; + actionId: string | null; + target: string | null; }); /** The `kind` discriminant of {@link AdminAccessEvent}. */ @@ -144,7 +201,7 @@ export function setAdminAccessSinkForTest(sink: AdminAccessSink | null): void { currentSink = sink ?? defaultSink; } -type AdminAccessUser = Pick; +type AdminAccessUser = Pick; /** * Input of {@link emitAdminAccessEvent}: the transport and identity fields plus @@ -154,29 +211,72 @@ type AdminAccessUser = Pick */ export type AdminAccessEventInput = { surface: AdminAccessSurface; - user: AdminAccessUser; authViaToken: boolean; tokenSource: string | null; + credential?: PrivilegedCredentialKind; + membershipRole?: string | null; + wouldPassWithoutAdmin?: boolean | null; + wouldBlock?: boolean; + userAgentProduct?: string | null; route: string | null; method: string | null; ip: string | null; } & ( - | { kind: 'admin_guard' } - | { kind: 'kilo_admin_elevation'; reason: KiloAdminElevationReason; target: string } + | { kind: 'admin_guard'; user: AdminAccessUser } + | { + kind: 'kilo_admin_elevation'; + user: AdminAccessUser; + reason: KiloAdminElevationReason; + target: string; + } + | { + kind: 'privileged_access'; + /** Null when the credential was refused before a user could be resolved. */ + user?: AdminAccessUser; + credential: PrivilegedCredentialKind; + outcome: 'succeeded' | 'denied' | 'failed'; + actionClass: PrivilegedActionClass; + actionId?: string | null; + target?: string | null; + } ); +export type PrivilegedAccessEventInput = { + surface: AdminAccessSurface; + user?: AdminAccessUser; + credential: PrivilegedCredentialKind; + outcome: 'succeeded' | 'denied' | 'failed'; + actionClass: PrivilegedActionClass; + actionId?: string | null; + target?: string | null; + wouldBlock?: boolean; + userAgentProduct?: string | null; + tokenSource: string | null; + route: string | null; + method: string | null; + ip: string | null; +}; + /** * Build and emit an `admin_access` event. Called from both admin guard choke * points; elevation sites should use {@link elevateViaKiloAdmin} or * {@link recordKiloAdminElevation} instead of calling this directly. */ export function emitAdminAccessEvent(params: AdminAccessEventInput): void { - const attribution = { - kiloUserId: params.user.id, - email: params.user.google_user_email, - adminTier: params.user.is_super_admin ? ('super_admin' as const) : ('platform_admin' as const), + const credential = + params.credential ?? + deriveCredentialKind({ + authViaToken: params.authViaToken, + tokenSource: params.tokenSource, + }); + const transport = { authVia: params.authViaToken ? ('token' as const) : ('session' as const), tokenSource: params.tokenSource, + credential, + membershipRole: params.membershipRole ?? null, + wouldPassWithoutAdmin: params.wouldPassWithoutAdmin ?? null, + wouldBlock: params.wouldBlock ?? credential !== 'session', + userAgentProduct: params.userAgentProduct ?? null, route: params.route, method: params.method, ip: params.ip, @@ -184,30 +284,79 @@ export function emitAdminAccessEvent(params: AdminAccessEventInput): void { // Audit logging is a side effect that must never deny a legitimate admin // action: swallow (and report) any sink failure rather than propagate it. try { - currentSink( - params.kind === 'kilo_admin_elevation' - ? { - event: 'admin_access', - surface: params.surface, - kind: 'kilo_admin_elevation', - ...attribution, - reason: params.reason, - target: params.target, - } - : { - event: 'admin_access', - surface: params.surface, - kind: 'admin_guard', - ...attribution, - reason: null, - target: null, - } - ); + if (params.kind === 'kilo_admin_elevation') { + currentSink({ + event: 'admin_access', + surface: params.surface, + kind: 'kilo_admin_elevation', + ...attributionFor(params.user), + ...transport, + reason: params.reason, + target: params.target, + }); + } else if (params.kind === 'privileged_access') { + currentSink({ + event: 'admin_access', + surface: params.surface, + kind: 'privileged_access', + ...nullableAttributionFor(params.user), + ...transport, + credential: params.credential, + outcome: params.outcome, + actionClass: params.actionClass, + actionId: params.actionId ?? null, + target: params.target ?? null, + }); + } else { + currentSink({ + event: 'admin_access', + surface: params.surface, + kind: 'admin_guard', + ...attributionFor(params.user), + ...transport, + reason: null, + target: null, + }); + } } catch (error) { captureException(error, { tags: { operation: 'admin_access_log' } }); } } +function attributionFor(user: AdminAccessUser) { + return { + kiloUserId: user.id, + email: user.google_user_email, + adminTier: user.is_super_admin ? ('super_admin' as const) : ('platform_admin' as const), + }; +} + +function nullableAttributionFor(user: AdminAccessUser | undefined) { + const adminTier = user?.is_super_admin + ? ('super_admin' as const) + : user?.is_admin + ? ('platform_admin' as const) + : null; + return { + kiloUserId: user?.id ?? null, + email: user?.is_admin ? user.google_user_email : null, + adminTier, + }; +} + +/** + * Emit one observe-only `privileged_access` event. It never denies; it exists + * so a bearer credential exercising platform authority is alertable while it is + * still allowed. + */ +export function emitPrivilegedAccessEvent(params: PrivilegedAccessEventInput): void { + emitAdminAccessEvent({ + ...params, + kind: 'privileged_access', + authViaToken: params.credential !== 'session', + }); +} + /** * The audit-relevant slice of a tRPC context. Declared structurally rather than * importing `TRPCContext` so this module stays free of a dependency cycle with @@ -223,11 +372,23 @@ export type AdminAuditContext = { user: AdminAccessUser; authViaToken?: boolean; tokenSource?: string | null; + /** Provenance derived once at the tRPC boundary, when available. */ + credential?: PrivilegedCredentialKind; + internalApiUse?: boolean; ip?: string | null; + headersList?: Headers; trpcPath?: string; trpcType?: string; }; +/** Membership observation carried by an elevation event. */ +export type ElevationMembershipObservation = { + /** The actor's real role in the target org, when measured. */ + membershipRole?: string | null; + /** Whether that real role alone would have authorized the request. */ + wouldPassWithoutAdmin?: boolean | null; +}; + /** * Record that the `is_admin` escape hatch granted a Kilo employee access to * data they have no membership-derived right to, on a code path that does not @@ -249,35 +410,66 @@ export type AdminAuditContext = { */ export async function recordKiloAdminElevation( ctx: AdminAuditContext, - params: { reason: KiloAdminElevationReason; target: string } + params: { reason: KiloAdminElevationReason; target: string } & ElevationMembershipObservation ): Promise { if (ctx.trpcPath === undefined) { - // `tokenSource` can only be preserved when the caller put it on the context, - // and today's REST handlers pass a bare `{ user }`. That is a deliberate - // limit, not an oversight: `authVia` — the discriminator that separates a - // stolen bearer token from a web-console session — is derived from the - // headers below and is always correct. `tokenSource` only adds granularity - // *within* the token case, and null is already one of its documented values. + // `tokenSource` is only preserved when the caller put it on the context. + // Some REST handlers still pass a bare `{ user }`, and their events carry + // null. `authVia` — the discriminator that separates a stolen bearer token + // from a web-console session — is derived from the request headers below and + // is always correct. `tokenSource` only adds granularity *within* the token + // case, and null is already one of its documented values. await recordKiloAdminElevationForRequest({ user: ctx.user, tokenSource: ctx.tokenSource, + internalApiUse: ctx.internalApiUse, reason: params.reason, target: params.target, + membershipRole: params.membershipRole, + wouldPassWithoutAdmin: params.wouldPassWithoutAdmin, }); return; } + const credential = + ctx.credential ?? + deriveCredentialKind({ + authViaToken: ctx.authViaToken ?? false, + tokenSource: ctx.tokenSource ?? null, + }); emitAdminAccessEvent({ surface: 'trpc', kind: 'kilo_admin_elevation', user: ctx.user, authViaToken: ctx.authViaToken ?? false, tokenSource: ctx.tokenSource ?? null, + credential, + membershipRole: params.membershipRole ?? null, + wouldPassWithoutAdmin: params.wouldPassWithoutAdmin ?? null, + wouldBlock: credential !== 'session', + userAgentProduct: ctx.headersList ? userAgentProductFromHeaders(ctx.headersList) : null, route: ctx.trpcPath, method: ctx.trpcType ?? null, ip: ctx.ip ?? null, reason: params.reason, target: params.target, }); + if (ctx.authViaToken) { + emitPrivilegedAccessEvent({ + surface: 'trpc', + user: ctx.user, + credential, + outcome: 'succeeded', + actionClass: actionClassForElevation(params.reason, ctx.trpcType ?? null), + actionId: null, + target: params.target, + wouldBlock: true, + userAgentProduct: ctx.headersList ? userAgentProductFromHeaders(ctx.headersList) : null, + tokenSource: ctx.tokenSource ?? null, + route: ctx.trpcPath, + method: ctx.trpcType ?? null, + ip: ctx.ip ?? null, + }); + } } /** @@ -300,9 +492,14 @@ export async function elevateViaKiloAdmin( /** See {@link organizationTarget} and {@link UNSCOPED_TARGET}. */ target: string; grant: TGrant; - } + } & ElevationMembershipObservation ): Promise { - await recordKiloAdminElevation(ctx, { reason: params.reason, target: params.target }); + await recordKiloAdminElevation(ctx, { + reason: params.reason, + target: params.target, + membershipRole: params.membershipRole, + wouldPassWithoutAdmin: params.wouldPassWithoutAdmin, + }); return params.grant; } @@ -322,43 +519,110 @@ export async function elevateViaKiloAdmin( * metadata rather than throwing into the caller's happy path, preserving the * rule that audit logging never denies a legitimate admin action. */ -export async function recordKiloAdminElevationForRequest(params: { - user: AdminAccessUser; - tokenSource?: string | null; - reason: KiloAdminElevationReason; - target: string; - surface?: AdminAccessSurface; -}): Promise { - let headersList: Headers; - try { - headersList = await headers(); - } catch { - headersList = new Headers(); +export async function recordKiloAdminElevationForRequest( + params: { + user: AdminAccessUser; + tokenSource?: string | null; + reason: KiloAdminElevationReason; + target: string; + surface?: AdminAccessSurface; + /** The actual Request method when the caller has one. */ + method?: string; + /** Use the caller's request headers instead of the ambient header store. */ + headersList?: Headers; + internalApiUse?: boolean; + delegatedWorkload?: boolean; + } & ElevationMembershipObservation +): Promise { + let headersList = params.headersList; + if (!headersList) { + try { + headersList = await headers(); + } catch { + headersList = new Headers(); + } } recordKiloAdminElevationFromHeaders({ ...params, headersList }); } -function recordKiloAdminElevationFromHeaders(params: { - user: AdminAccessUser; - headersList: Headers; - tokenSource?: string | null; - reason: KiloAdminElevationReason; - target: string; - surface?: AdminAccessSurface; -}): void { +function recordKiloAdminElevationFromHeaders( + params: { + user: AdminAccessUser; + headersList: Headers; + tokenSource?: string | null; + reason: KiloAdminElevationReason; + target: string; + surface?: AdminAccessSurface; + method?: string; + internalApiUse?: boolean; + delegatedWorkload?: boolean; + } & ElevationMembershipObservation +): void { + const authViaToken = authViaTokenFromHeaders(params.headersList); + const route = routeFromHeaders(params.headersList); + const method = params.method ?? methodFromHeaders(params.headersList); + const credential = deriveCredentialKind({ + authViaToken, + tokenSource: params.tokenSource ?? null, + internalApiUse: params.internalApiUse, + delegatedWorkload: params.delegatedWorkload, + }); emitAdminAccessEvent({ surface: params.surface ?? 'rest', kind: 'kilo_admin_elevation', user: params.user, - authViaToken: authViaTokenFromHeaders(params.headersList), + authViaToken, tokenSource: params.tokenSource ?? null, - route: routeFromHeaders(params.headersList), - // No reliable HTTP method header is available here; do not fabricate one. - method: null, + credential, + membershipRole: params.membershipRole ?? null, + wouldPassWithoutAdmin: params.wouldPassWithoutAdmin ?? null, + wouldBlock: credential !== 'session', + route, + method, + userAgentProduct: userAgentProductFromHeaders(params.headersList), ip: clientIpFromHeaders(params.headersList), reason: params.reason, target: params.target, }); + if (authViaToken) { + emitPrivilegedAccessEvent({ + surface: params.surface ?? 'rest', + user: params.user, + credential, + outcome: 'succeeded', + actionClass: actionClassForElevation(params.reason, method), + actionId: null, + target: params.target, + wouldBlock: true, + userAgentProduct: userAgentProductFromHeaders(params.headersList), + tokenSource: params.tokenSource ?? null, + route, + method, + ip: clientIpFromHeaders(params.headersList), + }); + } +} + +function actionClassForElevation( + reason: KiloAdminElevationReason, + method: string | null +): PrivilegedActionClass { + switch (reason) { + case 'organization_access_batch': + case 'organization_fetch': + case 'cli_session_cross_org_query': + case 'security_agent_audit_report': + return 'support_read'; + case 'organization_access': + return method === 'mutation' ? 'support_write' : 'support_read'; + case 'organization_access_rest': + return method === 'GET' || method === 'HEAD' ? 'support_read' : 'support_write'; + case 'code_index_user_override': + return 'support_write'; + case 'service_token_mint': + case 'delegated_resource_token_mint': + return 'worker_admin'; + } } /** `target` for a single organization. */ @@ -394,13 +658,13 @@ export function serviceTarget(service: string): string { export const UNSCOPED_TARGET = '*'; /** - * Whether a request authenticated via an API bearer token. This is the branch - * condition `getUserFromAuth`'s resolver takes — both call this predicate — so - * the emitted `authVia` label cannot diverge from the auth path that was - * actually taken. This is the compromise discriminator. + * Whether a request carries an API bearer credential. Any `Authorization` + * header counts, including an empty or malformed one: `getUserFromAuth` takes + * its bearer branch whenever the header is present and never falls back to the + * cookie, so the discriminator must use presence rather than truthiness. */ export function authViaTokenFromHeaders(headersList: Headers): boolean { - return Boolean(headersList.get('Authorization')); + return headersList.has('authorization'); } /** @@ -411,6 +675,31 @@ export function routeFromHeaders(headersList: Headers): string | null { return headersList.get('x-pathname') ?? headersList.get('x-matched-path') ?? null; } +/** + * HTTP method forwarded by `proxy.ts`. Route handlers have the Request, but the + * shared auth/audit helpers only receive headers. + */ +export function methodFromHeaders( + headersList: Headers, + requestMethod?: string | null +): string | null { + if (requestMethod) return requestMethod.toUpperCase(); + const method = headersList.get('x-http-method'); + return method === null || method === '' ? null : method.toUpperCase(); +} + +/** + * The first product token of the User-Agent (`Mozilla/5.0`, `kilo-cli/1.2`), + * never the full header. Null when absent. + */ +export function userAgentProductFromHeaders(headersList: Headers): string | null { + const userAgent = headersList.get('user-agent'); + if (userAgent === null) return null; + const first = userAgent.trim().split(/\s+/, 1)[0]; + if (first === '') return null; + return first.slice(0, 64); +} + /** * Extract the client IP from request headers. Prefers `x-forwarded-for`, with * `x-vercel-forwarded-for` as a fallback; returns the first hop or null. diff --git a/packages/web-shared/src/lib/admin/privileged-access.test.ts b/packages/web-shared/src/lib/admin/privileged-access.test.ts new file mode 100644 index 00000000000..c339a74d822 --- /dev/null +++ b/packages/web-shared/src/lib/admin/privileged-access.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from '@jest/globals'; +import { + ADMIN_STEP_UP_ABSENT, + createAdminAssurance, + deriveCredentialKind, + describePrivilegedAction, + type PrivilegedActor, +} from './privileged-access'; + +describe('credential provenance', () => { + test('classifies a request without Authorization as session', () => { + expect(deriveCredentialKind({ authViaToken: false })).toBe('session'); + }); + + test('classifies a personal bearer key as human_bearer', () => { + expect(deriveCredentialKind({ authViaToken: true })).toBe('human_bearer'); + expect(deriveCredentialKind({ authViaToken: true, tokenSource: 'personal' })).toBe( + 'human_bearer' + ); + }); + + test('classifies delegated workload tokens by source or scoped authority', () => { + expect(deriveCredentialKind({ authViaToken: true, tokenSource: 'cloud-agent' })).toBe( + 'delegated_workload' + ); + expect(deriveCredentialKind({ authViaToken: true, delegatedWorkload: true })).toBe( + 'delegated_workload' + ); + }); + + test('classifies internal-service and attested requests as service assertions', () => { + expect(deriveCredentialKind({ authViaToken: true, internalApiUse: true })).toBe( + 'service_assertion' + ); + expect(deriveCredentialKind({ authViaToken: true, serviceAssertion: true })).toBe( + 'service_assertion' + ); + }); +}); + +describe('AdminAssurance', () => { + test('has explicit permissions, an absent step-up, and a null support-grant slot', () => { + const actor: PrivilegedActor = { + assurance: createAdminAssurance({ + userId: 'user-1', + permissions: { + isAdmin: true, + isSuperAdmin: false, + canViewSessions: false, + canManageCredits: true, + canExportApiRequestLogs: false, + }, + }), + credential: 'human_bearer', + tokenSource: null, + action: describePrivilegedAction('sensitive_write', { kind: 'user', id: 'user-2' }, 'x'), + }; + + expect(actor.assurance.stepUp).toBe(ADMIN_STEP_UP_ABSENT); + expect(actor.assurance.supportGrant).toBeNull(); + expect(actor.assurance.permissions).toEqual({ + isAdmin: true, + isSuperAdmin: false, + canViewSessions: false, + canManageCredits: true, + canExportApiRequestLogs: false, + }); + expect(actor.action.actionClass).toBe('sensitive_write'); + expect(actor.action.inventoryId).toBe('x'); + }); +}); diff --git a/packages/web-shared/src/lib/admin/privileged-access.ts b/packages/web-shared/src/lib/admin/privileged-access.ts new file mode 100644 index 00000000000..f455eab84db --- /dev/null +++ b/packages/web-shared/src/lib/admin/privileged-access.ts @@ -0,0 +1,137 @@ +/** + * Observe-only contract for privileged platform access. + * + * This module defines the vocabulary and measurement only; it does not deny + * anything. Keep it dependency-free so both `apps/web` and + * `packages/web-shared` can import it from any runtime boundary, including + * tests. + */ + +export type PrivilegedCredentialKind = + | 'session' + | 'human_bearer' + | 'delegated_workload' + | 'service_assertion'; + +export type PrivilegedActionClass = + | 'platform_read' + | 'platform_write' + | 'sensitive_write' + | 'support_read' + | 'support_write' + | 'worker_admin'; + +export type PrivilegedTargetKind = 'organization' | 'user' | 'service' | 'none'; + +export type PrivilegedTarget = { + kind: PrivilegedTargetKind; + id: string | null; +}; + +export type PlatformAdminPermissions = { + isAdmin: boolean; + isSuperAdmin: boolean; + canViewSessions: boolean; + canManageCredits: boolean; + canExportApiRequestLogs: boolean; +}; + +export type AdminStepUpState = { status: 'absent' }; + +export type SupportGrantSlot = { + grantId: string; + organizationId: string; + expiresAt: string; +}; + +/** + * Privileged identity only: who is acting as a platform admin, what they may + * do, and how strongly they proved it. User authentication (the `User` row and + * its session) stays in the existing auth layer and is passed in by reference + * (the user id), never folded into this type. + */ +export type AdminAssurance = { + userId: string; + permissions: PlatformAdminPermissions; + stepUp: AdminStepUpState; + supportGrant: SupportGrantSlot | null; +}; + +export type PrivilegedAction = { + actionClass: PrivilegedActionClass; + target: PrivilegedTarget; + /** Stable id from the checked-in inventory, when one matched. */ + inventoryId: string | null; +}; + +export type PrivilegedActor = { + assurance: AdminAssurance; + credential: PrivilegedCredentialKind; + tokenSource: string | null; + action: PrivilegedAction; +}; + +export type CredentialProvenanceInput = { + /** True when the request carried any `Authorization` header. */ + authViaToken: boolean; + tokenSource?: string | null; + /** Internal-service tokens minted for trusted callers. */ + internalApiUse?: boolean; + /** A verified runtime-proxy attestation or scoped Cloud Agent authority. */ + serviceAssertion?: boolean; + /** A scoped, delegated Cloud Agent authority that is still a workload. */ + delegatedWorkload?: boolean; +}; + +/** + * Sources that mint a token for an agent, bot, or background workflow. They are + * not a human sitting at a browser, even when the underlying user is an admin. + * Anything else that arrives as a bearer token is treated as a human bearer + * key, which is the credential class this telemetry alerts on. + */ +const DELEGATED_WORKLOAD_TOKEN_SOURCES = new Set([ + 'cloud-agent', + 'code-review', + 'security-agent', + 'kilo-bot', + 'kilo-chat', + 'mcp-catalog', + 'internal-bot', + 'discord-bot', + 'gastown', + 'wasteland', +]); + +export function deriveCredentialKind(input: CredentialProvenanceInput): PrivilegedCredentialKind { + if (!input.authViaToken) return 'session'; + if (input.serviceAssertion || input.internalApiUse) return 'service_assertion'; + if ( + input.delegatedWorkload || + (input.tokenSource && DELEGATED_WORKLOAD_TOKEN_SOURCES.has(input.tokenSource)) + ) { + return 'delegated_workload'; + } + return 'human_bearer'; +} + +export function describePrivilegedAction( + actionClass: PrivilegedActionClass, + target: PrivilegedTarget = { kind: 'none', id: null }, + inventoryId: string | null = null +): PrivilegedAction { + return { actionClass, target, inventoryId }; +} +export const ADMIN_STEP_UP_ABSENT: AdminStepUpState = { status: 'absent' }; + +export function createAdminAssurance(params: { + userId: string; + permissions: PlatformAdminPermissions; + supportGrant?: SupportGrantSlot | null; +}): AdminAssurance { + return { + userId: params.userId, + permissions: params.permissions, + stepUp: ADMIN_STEP_UP_ABSENT, + supportGrant: params.supportGrant ?? null, + }; +} diff --git a/packages/web-shared/src/lib/admin/privileged-inventory.ts b/packages/web-shared/src/lib/admin/privileged-inventory.ts new file mode 100644 index 00000000000..58a30cf6c83 --- /dev/null +++ b/packages/web-shared/src/lib/admin/privileged-inventory.ts @@ -0,0 +1,1022 @@ +import type { + PrivilegedActionClass, + PrivilegedTargetKind, +} from '@kilocode/web-shared/lib/admin/privileged-access'; + +export type PrivilegedSurface = 'rest' | 'trpc' | 'server-component' | 'worker'; + +export const PRIVILEGED_GUARD_TIER = Symbol.for('kilocode.privilegedGuardTier'); + +export type PrivilegedGuardTier = + | 'admin' + | 'superadmin' + | 'credit_manager' + | 'session_viewer' + | 'api_request_log_exporter'; + +type GuardTagged = { [PRIVILEGED_GUARD_TIER]?: PrivilegedGuardTier }; + +/** Tags a guard middleware so tests can derive which procedures it protects. */ +export function markPrivilegedGuard(guard: T, tier: PrivilegedGuardTier): T { + (guard as GuardTagged)[PRIVILEGED_GUARD_TIER] = tier; + return guard; +} + +export function privilegedGuardTiers(middlewares: readonly unknown[]): PrivilegedGuardTier[] { + return middlewares.flatMap(middleware => { + const tier = (middleware as GuardTagged | null)?.[PRIVILEGED_GUARD_TIER]; + return tier === undefined ? [] : [tier]; + }); +} + +export type PrivilegedAccessKind = 'read' | 'write'; + +export type PrivilegedInventoryEntry = { + id: string; + surface: PrivilegedSurface; + /** Exact path/procedure, or a prefix ending in `*`. */ + route: string; + /** Action class for a read. Required when the surface serves reads. */ + read?: PrivilegedActionClass; + /** Action class for a write. Required when the surface serves writes. */ + write?: PrivilegedActionClass; + /** Used by surfaces without a read/write split (e.g. Worker admin gates). */ + actionClass?: PrivilegedActionClass; + target: PrivilegedTargetKind; +}; + +type AdminAreaPolicy = { + read: PrivilegedActionClass; + write: PrivilegedActionClass; + target: PrivilegedTargetKind; +}; + +const platform: AdminAreaPolicy = { + read: 'platform_read', + write: 'platform_write', + target: 'none', +}; + +const support: AdminAreaPolicy = { + read: 'support_read', + write: 'support_write', + target: 'organization', +}; + +const sensitiveWrite: AdminAreaPolicy = { + read: 'platform_read', + write: 'sensitive_write', + target: 'user', +}; + +/** + * One policy per mounted `admin.` router. A new area with no policy makes + * the inventory coverage test fail; a new procedure inside an existing area + * inherits the area's read/write class. + */ +const adminAreaPolicies: Record = { + actionAuditLogs: { read: 'support_read', write: 'support_write', target: 'none' }, + aiAttribution: platform, + alerting: platform, + apiRequestLog: { read: 'support_read', write: 'support_write', target: 'user' }, + autoFreeConfig: platform, + blacklistDomains: { read: 'platform_read', write: 'sensitive_write', target: 'none' }, + botRequests: platform, + bulkBlock: { read: 'platform_read', write: 'sensitive_write', target: 'user' }, + bulkUserCredits: { read: 'platform_read', write: 'sensitive_write', target: 'user' }, + cloudAgentNext: support, + cloudBillingSkus: platform, + codeReviews: support, + contributorChampions: { read: 'platform_read', write: 'sensitive_write', target: 'user' }, + creditCampaigns: { read: 'platform_read', write: 'sensitive_write', target: 'user' }, + customLlm: platform, + customPricing: platform, + disputes: sensitiveWrite, + earlyFraudWarnings: sensitiveWrite, + emailTesting: platform, + enkryptUsage: platform, + enrichmentData: { read: 'platform_read', write: 'platform_write', target: 'none' }, + extendClawTrial: platform, + featureInterest: platform, + gastown: support, + gatewayConfig: platform, + gatewayModelLists: platform, + github: platform, + impactReferrals: { read: 'platform_read', write: 'sensitive_write', target: 'user' }, + internalBotUsers: { read: 'platform_read', write: 'sensitive_write', target: 'user' }, + kiloclawInstances: platform, + kiloclawProviders: platform, + kiloclawRegions: platform, + kiloclawVersions: platform, + kiloPass: sensitiveWrite, + modelEvalIngest: platform, + modelStats: platform, + modelTraffic: platform, + ossSponsorship: { read: 'platform_read', write: 'sensitive_write', target: 'organization' }, + preferredProviderOrder: platform, + requestLoggingOptIns: platform, + salesDemo: { read: 'platform_read', write: 'sensitive_write', target: 'user' }, + serviceFeeRollback: { read: 'platform_read', write: 'sensitive_write', target: 'none' }, + securityAdvisorContent: support, + sessionTraces: { read: 'support_read', write: 'support_write', target: 'user' }, + syncProviders: platform, + userDataExports: { read: 'support_read', write: 'support_write', target: 'user' }, + userDeletionQueue: { read: 'platform_read', write: 'sensitive_write', target: 'user' }, + users: sensitiveWrite, + vercelModelIdOverrides: platform, + webhookTriggers: platform, +}; + +const adminRootPolicies: Record = { + getPermissions: { read: 'platform_read', write: 'platform_write', target: 'none' }, +}; + +function adminEntry(id: string, route: string, policy: AdminAreaPolicy): PrivilegedInventoryEntry { + return { + id, + surface: 'trpc', + route, + read: policy.read, + write: policy.write, + target: policy.target, + }; +} + +export const ADMIN_AREA_POLICIES = adminAreaPolicies; + +const trpcEntries: PrivilegedInventoryEntry[] = [ + ...Object.entries(adminRootPolicies).map(([area, policy]) => + adminEntry(`trpc.admin.${area}`, `admin.${area}`, policy) + ), + ...Object.entries(adminAreaPolicies).map(([area, policy]) => + adminEntry(`trpc.admin.${area}`, `admin.${area}.*`, policy) + ), + adminEntry('trpc.organizations', 'organizations.*', support), + adminEntry('trpc.organizations.admin', 'organizations.admin.*', support), + adminEntry('trpc.organizations.sso', 'organizations.sso.*', support), + { + id: 'trpc.organizations.seatPurchases', + surface: 'trpc', + route: 'organizations.seatPurchases', + read: 'support_read', + target: 'organization', + }, + { + id: 'trpc.organizations.updatePlan', + surface: 'trpc', + route: 'organizations.updatePlan', + write: 'support_write', + target: 'organization', + }, + { + id: 'trpc.organizations.updateSeatsRequired', + surface: 'trpc', + route: 'organizations.updateSeatsRequired', + write: 'support_write', + target: 'organization', + }, + adminEntry('trpc.codeIndexing.admin', 'codeIndexing.admin.*', { + read: 'platform_read', + write: 'support_write', + target: 'user', + }), + { + id: 'trpc.codingPlans.adminAvailabilityIntentCounts', + surface: 'trpc', + route: 'codingPlans.adminAvailabilityIntentCounts', + read: 'platform_read', + target: 'user', + }, + { + id: 'trpc.codingPlans.adminInsights', + surface: 'trpc', + route: 'codingPlans.adminInsights', + read: 'platform_read', + target: 'user', + }, + { + id: 'trpc.codingPlans.adminKeyInventory', + surface: 'trpc', + route: 'codingPlans.adminKeyInventory', + read: 'platform_read', + target: 'user', + }, + { + id: 'trpc.codingPlans.adminListSubscriptions', + surface: 'trpc', + route: 'codingPlans.adminListSubscriptions', + read: 'platform_read', + target: 'user', + }, + { + id: 'trpc.codingPlans.adminRevocationQueue', + surface: 'trpc', + route: 'codingPlans.adminRevocationQueue', + read: 'platform_read', + target: 'user', + }, + { + id: 'trpc.codingPlans.adminSubscriptionOverview', + surface: 'trpc', + route: 'codingPlans.adminSubscriptionOverview', + read: 'platform_read', + target: 'user', + }, + { + id: 'trpc.codingPlans.adminCancelSubscription', + surface: 'trpc', + route: 'codingPlans.adminCancelSubscription', + write: 'sensitive_write', + target: 'user', + }, + { + id: 'trpc.codingPlans.adminExtendSubscriptionPeriod', + surface: 'trpc', + route: 'codingPlans.adminExtendSubscriptionPeriod', + write: 'sensitive_write', + target: 'user', + }, + { + id: 'trpc.codingPlans.adminMarkRevocationComplete', + surface: 'trpc', + route: 'codingPlans.adminMarkRevocationComplete', + write: 'sensitive_write', + target: 'user', + }, + { + id: 'trpc.codingPlans.adminMarkRevocationFailed', + surface: 'trpc', + route: 'codingPlans.adminMarkRevocationFailed', + write: 'sensitive_write', + target: 'user', + }, + { + id: 'trpc.codingPlans.adminReassignSubscriptionCredential', + surface: 'trpc', + route: 'codingPlans.adminReassignSubscriptionCredential', + write: 'sensitive_write', + target: 'user', + }, + { + id: 'trpc.codingPlans.adminReduceInventory', + surface: 'trpc', + route: 'codingPlans.adminReduceInventory', + write: 'sensitive_write', + target: 'user', + }, + { + id: 'trpc.codingPlans.adminReplaceInventoryCredential', + surface: 'trpc', + route: 'codingPlans.adminReplaceInventoryCredential', + write: 'sensitive_write', + target: 'user', + }, + { + id: 'trpc.codingPlans.adminReplaceRevocationCredential', + surface: 'trpc', + route: 'codingPlans.adminReplaceRevocationCredential', + write: 'sensitive_write', + target: 'user', + }, + { + id: 'trpc.codingPlans.adminRequeueRevocation', + surface: 'trpc', + route: 'codingPlans.adminRequeueRevocation', + write: 'sensitive_write', + target: 'user', + }, + { + id: 'trpc.codingPlans.adminTerminateSubscription', + surface: 'trpc', + route: 'codingPlans.adminTerminateSubscription', + write: 'sensitive_write', + target: 'user', + }, + { + id: 'trpc.codingPlans.adminUploadKeys', + surface: 'trpc', + route: 'codingPlans.adminUploadKeys', + write: 'sensitive_write', + target: 'user', + }, + { + id: 'trpc.debug.badInputError', + surface: 'trpc', + route: 'debug.badInputError', + read: 'platform_read', + target: 'none', + }, + { + id: 'trpc.debug.badInputObjectError', + surface: 'trpc', + route: 'debug.badInputObjectError', + read: 'platform_read', + target: 'none', + }, + { + id: 'trpc.debug.handledTrpcError', + surface: 'trpc', + route: 'debug.handledTrpcError', + read: 'platform_read', + target: 'none', + }, + { + id: 'trpc.debug.unhandledError', + surface: 'trpc', + route: 'debug.unhandledError', + read: 'platform_read', + target: 'none', + }, + { + id: 'trpc.test.adminHello', + surface: 'trpc', + route: 'test.adminHello', + read: 'platform_read', + target: 'none', + }, + { + id: 'trpc.cliSessionsV2.list', + surface: 'trpc', + route: 'cliSessionsV2.list', + read: 'support_read', + target: 'none', + }, + { + id: 'trpc.cliSessionsV2.search', + surface: 'trpc', + route: 'cliSessionsV2.search', + read: 'support_read', + target: 'none', + }, + adminEntry('trpc.codeIndexing', 'codeIndexing.*', { + read: 'support_read', + write: 'support_write', + target: 'user', + }), + { + id: 'trpc.securityAgent.getAuditReport', + surface: 'trpc', + route: 'securityAgent.getAuditReport', + read: 'support_read', + target: 'user', + }, + { + id: 'trpc.organizations.securityAgent.getAuditReport', + surface: 'trpc', + route: 'organizations.securityAgent.getAuditReport', + read: 'support_read', + target: 'organization', + }, +]; + +const restEntries: PrivilegedInventoryEntry[] = [ + { + id: 'rest.admin.users', + surface: 'rest', + route: '/admin/api/users*', + read: 'support_read', + write: 'sensitive_write', + target: 'user', + }, + { + id: 'rest.admin.enrichmentData', + surface: 'rest', + route: '/admin/api/enrichment-data*', + write: 'platform_write', + target: 'none', + }, + { + id: 'rest.admin.apiRequestLog', + surface: 'rest', + route: '/admin/api/api-request-log*', + read: 'support_read', + target: 'user', + }, + { + id: 'rest.admin.organizations', + surface: 'rest', + route: '/admin/api/organizations*', + read: 'support_read', + write: 'support_write', + target: 'organization', + }, + { + id: 'rest.admin.abuse', + surface: 'rest', + route: '/admin/api/abuse*', + read: 'platform_read', + write: 'sensitive_write', + target: 'user', + }, + { + id: 'rest.admin.backfills', + surface: 'rest', + route: '/admin/api/backfills*', + read: 'platform_read', + write: 'sensitive_write', + target: 'none', + }, + { + id: 'rest.admin.accountDeduplication', + surface: 'rest', + route: '/admin/api/account-deduplication*', + read: 'platform_read', + write: 'sensitive_write', + target: 'user', + }, + { + id: 'rest.admin.safetyIdentifiers', + surface: 'rest', + route: '/admin/api/safety-identifiers*', + read: 'platform_read', + write: 'sensitive_write', + target: 'user', + }, + { + id: 'rest.admin.autoRouting', + surface: 'rest', + route: '/admin/api/auto-routing*', + read: 'platform_read', + write: 'platform_write', + target: 'none', + }, + { + id: 'rest.admin.analytics', + surface: 'rest', + route: '/admin/api/gastown-analytics*', + read: 'support_read', + target: 'none', + }, + { + id: 'rest.admin.kiloclawAnalytics', + surface: 'rest', + route: '/admin/api/kiloclaw-analytics*', + read: 'platform_read', + target: 'none', + }, + { + id: 'rest.admin.kiloclawControllerTelemetry', + surface: 'rest', + route: '/admin/api/kiloclaw-controller-telemetry*', + read: 'platform_read', + target: 'none', + }, + { + id: 'rest.admin.slackBot', + surface: 'rest', + route: '/admin/api/slack-bot*', + read: 'platform_read', + target: 'none', + }, + { + id: 'rest.admin.creditCategories', + surface: 'rest', + route: '/admin/api/credit-categories*', + read: 'platform_read', + write: 'platform_write', + target: 'none', + }, + { + id: 'rest.admin.revenue', + surface: 'rest', + route: '/admin/api/revenue*', + read: 'platform_read', + target: 'none', + }, + { + id: 'rest.private.users', + surface: 'rest', + route: '/api/private/users*', + read: 'support_read', + target: 'user', + }, + { + id: 'rest.orgRoutes', + surface: 'rest', + route: '/api/organizations*', + read: 'support_read', + write: 'support_write', + target: 'organization', + }, + { + id: 'rest.orgPages', + surface: 'rest', + route: '/organizations*', + read: 'support_read', + write: 'support_write', + target: 'organization', + }, + { + id: 'rest.codeIndexing', + surface: 'rest', + route: '/api/code-indexing*', + read: 'support_read', + target: 'user', + }, + { + id: 'rest.integrations', + surface: 'rest', + route: '/api/integrations*', + read: 'support_read', + write: 'support_write', + target: 'organization', + }, + { + id: 'rest.cloudAgent', + surface: 'rest', + route: '/api/cloud-agent*', + read: 'support_read', + write: 'support_write', + target: 'organization', + }, + { + id: 'rest.autoRouting', + surface: 'rest', + route: '/api/auto-routing*', + read: 'support_read', + write: 'support_write', + target: 'organization', + }, + { + id: 'rest.claw', + surface: 'rest', + route: '/api/claw*', + read: 'support_read', + write: 'support_write', + target: 'organization', + }, + { + id: 'rest.internal', + surface: 'rest', + route: '/api/internal*', + read: 'support_read', + write: 'support_write', + target: 'none', + }, + { + id: 'rest.payments.topup', + surface: 'rest', + route: '/payments/topup*', + write: 'support_write', + target: 'user', + }, + { + id: 'rest.wastelandToken', + surface: 'rest', + route: '/api/wasteland/token', + write: 'worker_admin', + target: 'service', + }, + { + id: 'rest.gastownToken', + surface: 'rest', + route: '/api/gastown/token', + write: 'worker_admin', + target: 'service', + }, + { + id: 'rest.organizationTokens', + surface: 'rest', + route: '/api/organizations/[id]/user-tokens', + write: 'support_write', + target: 'organization', + }, +]; + +const serverComponentEntries: PrivilegedInventoryEntry[] = [ + { + id: 'serverComponent.admin', + surface: 'server-component', + route: '/admin*', + read: 'platform_read', + write: 'sensitive_write', + target: 'none', + }, + { + id: 'serverComponent.organizations', + surface: 'server-component', + route: '/organizations*', + read: 'support_read', + write: 'support_write', + target: 'organization', + }, +]; + +const workerEntries: PrivilegedInventoryEntry[] = [ + { + id: 'worker.gastown.admin', + surface: 'worker', + route: 'gastown.admin*', + actionClass: 'worker_admin', + target: 'service', + }, + { + id: 'worker.gastown.debugAgentMetadata', + surface: 'worker', + route: 'gastown.debugAgentMetadata', + actionClass: 'worker_admin', + target: 'service', + }, + { + id: 'worker.wasteland.admin', + surface: 'worker', + route: 'wasteland.admin*', + actionClass: 'worker_admin', + target: 'service', + }, +]; + +export const privilegedInventory: readonly PrivilegedInventoryEntry[] = [ + ...trpcEntries, + ...restEntries, + ...serverComponentEntries, + ...workerEntries, +]; + +/** + * Server-side files that read `is_admin` outside the checked-in route/procedure + * inventory (shared helpers, token issuance, and branch-level bypasses). + * `occurrences` records the reviewed count per file, so a new branch in an + * already-listed file fails until it is reviewed. `file` is repository-relative. + */ +export type InternalElevationSite = { + id: string; + file: string; + actionClass: PrivilegedActionClass; + surface: PrivilegedSurface; + occurrences: number; +}; + +export const internalElevationSites: readonly InternalElevationSite[] = [ + { + id: 'internal.app.gastown.agents', + file: 'apps/web/src/app/(app)/gastown/[townId]/agents/page.tsx', + actionClass: 'support_read', + surface: 'server-component', + occurrences: 1, + }, + { + id: 'internal.app.gastown.beads', + file: 'apps/web/src/app/(app)/gastown/[townId]/beads/page.tsx', + actionClass: 'support_read', + surface: 'server-component', + occurrences: 1, + }, + { + id: 'internal.app.gastown.mail', + file: 'apps/web/src/app/(app)/gastown/[townId]/mail/page.tsx', + actionClass: 'support_read', + surface: 'server-component', + occurrences: 1, + }, + { + id: 'internal.app.gastown.merges', + file: 'apps/web/src/app/(app)/gastown/[townId]/merges/page.tsx', + actionClass: 'support_read', + surface: 'server-component', + occurrences: 1, + }, + { + id: 'internal.app.gastown.observability', + file: 'apps/web/src/app/(app)/gastown/[townId]/observability/page.tsx', + actionClass: 'support_read', + surface: 'server-component', + occurrences: 1, + }, + { + id: 'internal.app.gastown.overview', + file: 'apps/web/src/app/(app)/gastown/[townId]/page.tsx', + actionClass: 'support_read', + surface: 'server-component', + occurrences: 1, + }, + { + id: 'internal.app.gastown.rig', + file: 'apps/web/src/app/(app)/gastown/[townId]/rigs/[rigId]/page.tsx', + actionClass: 'support_read', + surface: 'server-component', + occurrences: 1, + }, + { + id: 'internal.app.gastown.rigSettings', + file: 'apps/web/src/app/(app)/gastown/[townId]/rigs/[rigId]/settings/page.tsx', + actionClass: 'support_read', + surface: 'server-component', + occurrences: 1, + }, + { + id: 'internal.app.gastown.settings', + file: 'apps/web/src/app/(app)/gastown/[townId]/settings/page.tsx', + actionClass: 'support_read', + surface: 'server-component', + occurrences: 1, + }, + { + id: 'internal.app.gastown.onboarding', + file: 'apps/web/src/app/(app)/gastown/onboarding/page.tsx', + actionClass: 'support_read', + surface: 'server-component', + occurrences: 1, + }, + { + id: 'internal.app.gastown.root', + file: 'apps/web/src/app/(app)/gastown/page.tsx', + actionClass: 'support_read', + surface: 'server-component', + occurrences: 1, + }, + { + id: 'internal.app.organizations.welcome', + file: 'apps/web/src/app/(app)/organizations/[id]/welcome/layout.tsx', + actionClass: 'support_read', + surface: 'server-component', + occurrences: 1, + }, + { + id: 'internal.components.adminUserStatusBadge', + file: 'apps/web/src/components/admin/UserStatusBadge.tsx', + actionClass: 'platform_read', + surface: 'server-component', + occurrences: 1, + }, + { + id: 'internal.components.organizationByPageLayout', + file: 'apps/web/src/components/organizations/OrganizationByPageLayout.tsx', + actionClass: 'support_read', + surface: 'server-component', + occurrences: 2, + }, + { + id: 'internal.app.admin.creditCategories', + file: 'apps/web/src/app/admin/api/credit-categories/[key]/route.ts', + actionClass: 'platform_write', + surface: 'rest', + occurrences: 2, + }, + { + id: 'internal.app.gastownToken', + file: 'apps/web/src/app/api/gastown/token/route.ts', + actionClass: 'worker_admin', + surface: 'rest', + occurrences: 2, + }, + { + id: 'internal.app.wastelandToken', + file: 'apps/web/src/app/api/wasteland/token/route.ts', + actionClass: 'worker_admin', + surface: 'rest', + occurrences: 2, + }, + { + id: 'internal.abuse.blockBlacklistedDomains', + file: 'apps/web/src/lib/abuse/block-blacklisted-domains.ts', + actionClass: 'sensitive_write', + surface: 'rest', + occurrences: 1, + }, + { + id: 'internal.admin.actionAudit', + file: 'apps/web/src/lib/admin/admin-action-audit.ts', + actionClass: 'platform_read', + surface: 'rest', + occurrences: 1, + }, + { + id: 'internal.admin.permissions', + file: 'apps/web/src/lib/admin/admin-permissions.ts', + actionClass: 'platform_read', + surface: 'trpc', + occurrences: 3, + }, + { + id: 'internal.admin.creditManagement', + file: 'apps/web/src/lib/admin/credit-management.ts', + actionClass: 'platform_read', + surface: 'trpc', + occurrences: 1, + }, + { + id: 'internal.admin.githubInstallationUninstall', + file: 'apps/web/src/lib/admin/github-installation-uninstall.ts', + actionClass: 'support_write', + surface: 'trpc', + occurrences: 1, + }, + { + id: 'internal.auth.resourceDelegation', + file: 'apps/web/src/lib/auth/resource-delegation.ts', + actionClass: 'worker_admin', + surface: 'rest', + occurrences: 3, + }, + { + id: 'internal.codeIndexing.util', + file: 'apps/web/src/lib/code-indexing/util.ts', + actionClass: 'platform_read', + surface: 'trpc', + occurrences: 1, + }, + { + id: 'internal.bitbucket.credentials', + file: 'apps/web/src/lib/integrations/platforms/bitbucket/credentials.ts', + actionClass: 'support_write', + surface: 'rest', + occurrences: 1, + }, + { + id: 'internal.bitbucket.workspaceAccessTokenAuthorization', + file: 'apps/web/src/lib/integrations/platforms/bitbucket/workspace-access-token-organization-authorization.ts', + actionClass: 'support_write', + surface: 'rest', + occurrences: 1, + }, + { + id: 'internal.organizations.organizationAuth', + file: 'apps/web/src/lib/organizations/organization-auth.ts', + actionClass: 'support_write', + surface: 'rest', + occurrences: 1, + }, + { + id: 'internal.securityAgent.sharedHandlers', + file: 'apps/web/src/lib/security-agent/router/shared-handlers.ts', + actionClass: 'support_read', + surface: 'trpc', + occurrences: 5, + }, + { + id: 'internal.user.deletionLegacyBackfill', + file: 'apps/web/src/lib/user/deletion-queue/deletion-legacy-backfill.ts', + actionClass: 'sensitive_write', + surface: 'rest', + occurrences: 2, + }, + { + id: 'internal.wasteland.serverResolve', + file: 'apps/web/src/lib/wasteland/server-resolve.ts', + actionClass: 'support_read', + surface: 'rest', + occurrences: 2, + }, + { + id: 'internal.routers.adminKiloclawInstances', + file: 'apps/web/src/routers/admin-kiloclaw-instances-router.ts', + actionClass: 'platform_write', + surface: 'trpc', + occurrences: 3, + }, + { + id: 'internal.routers.admin', + file: 'apps/web/src/routers/admin-router.ts', + actionClass: 'platform_write', + surface: 'trpc', + occurrences: 10, + }, + { + id: 'internal.routers.cliSessionsV2', + file: 'apps/web/src/routers/cli-sessions-v2-router.ts', + actionClass: 'support_read', + surface: 'trpc', + occurrences: 1, + }, + { + id: 'internal.routers.codeIndexing', + file: 'apps/web/src/routers/code-indexing/code-indexing-router.ts', + actionClass: 'support_read', + surface: 'trpc', + occurrences: 1, + }, + { + id: 'internal.routers.githubApps', + file: 'apps/web/src/routers/github-apps-router.ts', + actionClass: 'support_write', + surface: 'trpc', + occurrences: 3, + }, + { + id: 'internal.routers.organizationCodeReviews', + file: 'apps/web/src/routers/organizations/organization-code-reviews-router.ts', + actionClass: 'support_read', + surface: 'trpc', + occurrences: 2, + }, + { + id: 'internal.routers.organizationMembers', + file: 'apps/web/src/routers/organizations/organization-members-router.ts', + actionClass: 'support_write', + surface: 'trpc', + occurrences: 3, + }, + { + id: 'internal.routers.organizationSubOrganizations', + file: 'apps/web/src/routers/organizations/organization-sub-organizations-router.ts', + actionClass: 'support_write', + surface: 'trpc', + occurrences: 1, + }, + { + id: 'internal.routers.user', + file: 'apps/web/src/routers/user-router.ts', + actionClass: 'platform_read', + surface: 'trpc', + occurrences: 1, + }, + { + id: 'internal.user.nextAuthOptions', + file: 'apps/web/src/lib/user/next-auth-options.ts', + actionClass: 'platform_read', + surface: 'server-component', + occurrences: 3, + }, + { + id: 'internal.webShared.trpcInit', + file: 'packages/web-shared/src/lib/trpc/init.ts', + actionClass: 'platform_read', + surface: 'trpc', + occurrences: 1, + }, + { + id: 'internal.webShared.userServer', + file: 'packages/web-shared/src/lib/user/server.ts', + actionClass: 'platform_read', + surface: 'rest', + occurrences: 1, + }, + { + id: 'internal.webShared.organizationsUtils', + file: 'packages/web-shared/src/routers/organizations/utils.ts', + actionClass: 'support_write', + surface: 'trpc', + occurrences: 3, + }, +]; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function matchesRoute(pattern: string, route: string): boolean { + if (pattern.includes('[')) { + const expression = escapeRegExp(pattern).replace(/\\\[[^\]]+\\\]/g, '[^/]+'); + return new RegExp(`^${expression}$`).test(route); + } + if (!pattern.endsWith('*')) return route === pattern; + const prefix = pattern.slice(0, -1); + if (prefix.startsWith('/')) { + return route === prefix || route.startsWith(`${prefix}/`); + } + return route.startsWith(prefix); +} + +function matchScore(pattern: string): number { + return pattern.endsWith('*') ? pattern.length - 1 : Number.MAX_SAFE_INTEGER; +} + +export function findPrivilegedInventoryEntry(params: { + surface: PrivilegedSurface; + route: string; + access?: PrivilegedAccessKind; +}): PrivilegedInventoryEntry | undefined { + const candidates = privilegedInventory.filter( + entry => + entry.surface === params.surface && + matchesRoute(entry.route, params.route) && + (params.access === undefined || entry[params.access] !== undefined) + ); + return candidates.sort((a, b) => matchScore(b.route) - matchScore(a.route))[0]; +} + +export function actionClassForEntry( + entry: PrivilegedInventoryEntry | undefined, + access: PrivilegedAccessKind +): PrivilegedActionClass { + return ( + entry?.[access] ?? + entry?.actionClass ?? + (access === 'read' ? 'platform_read' : 'platform_write') + ); +} + +export function actionClassForAdminProcedure( + path: string, + type: string +): { actionClass: PrivilegedActionClass; inventoryId: string | null } { + const access: PrivilegedAccessKind = type === 'mutation' ? 'write' : 'read'; + const entry = findPrivilegedInventoryEntry({ surface: 'trpc', route: path, access }); + return { actionClass: actionClassForEntry(entry, access), inventoryId: entry?.id ?? null }; +} + +export function actionClassForRoute(params: { + surface: Exclude; + route: string; + method: string | null; +}): { actionClass: PrivilegedActionClass; inventoryId: string | null } { + const access: PrivilegedAccessKind = + params.method === 'GET' || params.method === 'HEAD' ? 'read' : 'write'; + const entry = findPrivilegedInventoryEntry({ + surface: params.surface, + route: params.route, + access, + }); + return { actionClass: actionClassForEntry(entry, access), inventoryId: entry?.id ?? null }; +} diff --git a/packages/web-shared/src/lib/trpc/init.test.ts b/packages/web-shared/src/lib/trpc/init.test.ts index 0a770f02105..3454978b7b3 100644 --- a/packages/web-shared/src/lib/trpc/init.test.ts +++ b/packages/web-shared/src/lib/trpc/init.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from '@jest/globals'; import * as z from 'zod'; +import { TRPCError } from '@trpc/server'; import type { CloudAgentAuthority } from '@kilocode/worker-utils/cloud-agent-authority'; import { adminProcedure, @@ -18,6 +19,11 @@ import { defineTestUser } from '@kilocode/web-shared/tests/helpers/user.helper'; const testRouter = createTRPCRouter({ ping: adminProcedure.query(() => 'ok'), + chainedDenial: adminProcedure + .use(async () => { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Specialized guard denied' }); + }) + .query(() => 'unreachable'), // Stands in for the `is_admin` bypasses on regular procedures, which can only // name their procedure if `baseProcedure` publishes it onto the context. bypass: baseProcedure.mutation(async ({ ctx }) => { @@ -78,14 +84,60 @@ describe('adminProcedure admin_access telemetry', () => { await caller.ping(); - expect(events).toHaveLength(1); + expect(events.map(event => event.kind)).toEqual(['admin_guard', 'privileged_access']); expect(events[0]).toMatchObject({ surface: 'trpc', kind: 'admin_guard', authVia: 'token', adminTier: 'platform_admin', + credential: 'delegated_workload', + wouldBlock: true, tokenSource: 'cloud-agent', }); + expect(events[1]).toMatchObject({ + kind: 'privileged_access', + credential: 'delegated_workload', + outcome: 'succeeded', + actionClass: 'platform_read', + }); + }); + + test('emits denied, not succeeded, when a chained guard refuses the request', async () => { + const caller = createCaller( + ctxFor({ + user: defineTestUser({ is_admin: true, is_super_admin: false }), + authViaToken: true, + }) + ); + + await expect(caller.chainedDenial()).rejects.toThrow(); + + expect(events.map(event => event.kind)).toEqual(['admin_guard', 'privileged_access']); + expect(events[1]).toMatchObject({ + kind: 'privileged_access', + credential: 'human_bearer', + outcome: 'denied', + wouldBlock: true, + }); + }); + + test('emits a denied privileged_access event for a non-admin bearer caller', async () => { + const caller = createCaller( + ctxFor({ user: defineTestUser({ is_admin: false }), authViaToken: true }) + ); + + await expect(caller.ping()).rejects.toThrow(); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + kind: 'privileged_access', + credential: 'human_bearer', + outcome: 'denied', + actionClass: 'platform_read', + adminTier: null, + email: null, + wouldBlock: true, + }); }); test('does not emit for a non-admin caller', async () => { diff --git a/packages/web-shared/src/lib/trpc/init.ts b/packages/web-shared/src/lib/trpc/init.ts index cd9ac7ca366..c76a91e481f 100644 --- a/packages/web-shared/src/lib/trpc/init.ts +++ b/packages/web-shared/src/lib/trpc/init.ts @@ -14,7 +14,17 @@ import { authViaTokenFromHeaders, clientIpFromHeaders, emitAdminAccessEvent, + emitPrivilegedAccessEvent, + userAgentProductFromHeaders, } from '@kilocode/web-shared/lib/admin/admin-access-log'; +import { + deriveCredentialKind, + type PrivilegedCredentialKind, +} from '@kilocode/web-shared/lib/admin/privileged-access'; +import { + actionClassForAdminProcedure, + markPrivilegedGuard, +} from '@kilocode/web-shared/lib/admin/privileged-inventory'; import { setTag, trpcMiddleware } from '@sentry/nextjs'; import { AuthContextError, trpcErrorFormatter } from '@kilocode/web-shared/lib/trpc/transport'; import { @@ -47,6 +57,14 @@ export type TRPCContext = { // production `createTRPCContext` always populates them. authViaToken?: boolean; tokenSource?: string | null; + /** + * Provenance derived once at the request boundary. `adminProcedure` and the + * privileged-access telemetry read this instead of re-deriving from + * `authViaToken` so a scoped or delegated token cannot be mislabeled as a + * human session. + */ + credential?: PrivilegedCredentialKind; + internalApiUse?: boolean; ip?: string | null; // Procedure path and type, injected into the context by `baseProcedure` so // that `is_admin` elevation sites *inside* a resolver (which only receive @@ -84,19 +102,20 @@ export type CreateTRPCContextOpts = { export const createTRPCContext = async (opts?: CreateTRPCContextOpts): Promise => { const headersList = opts?.req?.headers ?? (await headers()); const startedAt = performance.now(); - const { user, deviceSessionId, tokenSource, cloudAgentAuthority } = await withControlPlaneBudget({ - path: controlPlanePathFromInfo(opts?.info), - type: controlPlaneTypeFromInfo(opts?.info), - headersList, - next: () => - getUserFromAuth({ - adminOnly: false, - requestHeaders: headersList, - requestPathname: opts?.req ? new URL(opts.req.url).pathname : undefined, - }), - startedAt, - surface: 'context', - }); + const { user, deviceSessionId, tokenSource, cloudAgentAuthority, internalApiUse } = + await withControlPlaneBudget({ + path: controlPlanePathFromInfo(opts?.info), + type: controlPlaneTypeFromInfo(opts?.info), + headersList, + next: () => + getUserFromAuth({ + adminOnly: false, + requestHeaders: headersList, + requestPathname: opts?.req ? new URL(opts.req.url).pathname : undefined, + }), + startedAt, + surface: 'context', + }); if (!user) { throw new TRPCError({ code: 'UNAUTHORIZED', @@ -108,11 +127,19 @@ export const createTRPCContext = async (opts?: CreateTRPCContextOpts): Promise { +type AdminGuardFn = Parameters[0]; +const adminGuardFn = markPrivilegedGuard(async ({ ctx, path, type, next }) => { + const credential = + ctx.credential ?? + deriveCredentialKind({ + authViaToken: ctx.authViaToken ?? false, + tokenSource: ctx.tokenSource ?? null, + internalApiUse: ctx.internalApiUse, + }); + const isBearer = credential !== 'session'; + const { actionClass, inventoryId } = actionClassForAdminProcedure(path, type); + const emitPrivileged = (outcome: 'succeeded' | 'denied' | 'failed') => + emitPrivilegedAccessEvent({ + surface: 'trpc', + user: ctx.user, + credential, + outcome, + actionClass, + actionId: inventoryId, + target: null, + wouldBlock: true, + userAgentProduct: userAgentProductFromHeaders(ctx.headersList ?? new Headers()), + tokenSource: ctx.tokenSource ?? null, + route: path, + method: type, + ip: ctx.ip ?? null, + }); + if (!ctx.user.is_admin) { + if (isBearer) emitPrivileged('denied'); throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin access required', }); } - // Emit before next() so the access attempt is recorded even if the handler - // (or a chained sub-check) later throws. emitAdminAccessEvent({ surface: 'trpc', kind: 'admin_guard', user: ctx.user, authViaToken: ctx.authViaToken ?? false, tokenSource: ctx.tokenSource ?? null, + credential, + wouldBlock: isBearer, + userAgentProduct: userAgentProductFromHeaders(ctx.headersList ?? new Headers()), route: path, method: type, ip: ctx.ip ?? null, }); - return next(); -}); + try { + const result = await next(); + if (isBearer) { + if (result.ok) { + emitPrivileged('succeeded'); + } else if (result.error.code === 'FORBIDDEN' || result.error.code === 'UNAUTHORIZED') { + emitPrivileged('denied'); + } else { + emitPrivileged('failed'); + } + } + return result; + } catch (error) { + if (isBearer) emitPrivileged('failed'); + throw error; + } +}, 'admin'); + +const adminGuardMiddleware = t.middleware(adminGuardFn); + +export const adminProcedure = baseProcedure.use(adminGuardMiddleware); diff --git a/packages/web-shared/src/lib/trpc/route-handler.ts b/packages/web-shared/src/lib/trpc/route-handler.ts index 72e33dcba3f..e3cf769e71e 100644 --- a/packages/web-shared/src/lib/trpc/route-handler.ts +++ b/packages/web-shared/src/lib/trpc/route-handler.ts @@ -24,15 +24,12 @@ export function createTRPCRouteHandler(createCaller: (ctx: TRPCContext) handler: (caller: TCaller) => Promise ): Promise> { try { - // Create tRPC context and caller - const ctx = await createTRPCContext(); + const ctx = await createTRPCContext({ req: request }); const caller = createCaller(ctx); - // Execute the handler and return the result const result = await handler(caller); return NextResponse.json(result); } catch (error) { - // Handle tRPC errors specifically if (error instanceof TRPCError) { logTRPCError(error, { extra: { @@ -48,7 +45,6 @@ export function createTRPCRouteHandler(createCaller: (ctx: TRPCContext) } else { captureException(error); - // Generic error response return NextResponse.json( { error: 'Internal Server Error', diff --git a/packages/web-shared/src/lib/user/server.test.ts b/packages/web-shared/src/lib/user/server.test.ts index b5f2a51a5a7..2d031dcb531 100644 --- a/packages/web-shared/src/lib/user/server.test.ts +++ b/packages/web-shared/src/lib/user/server.test.ts @@ -1456,13 +1456,15 @@ describe('getUserFromAuth admin_access telemetry (REST)', () => { const result = await getUserFromAuth({ adminOnly: true }); expect(result.user?.id).toBe(admin.id); - expect(events).toHaveLength(1); + expect(events).toHaveLength(2); expect(events[0]).toMatchObject({ event: 'admin_access', surface: 'rest', kind: 'admin_guard', authVia: 'token', adminTier: 'super_admin', + credential: 'human_bearer', + wouldBlock: true, kiloUserId: admin.id, email: admin.google_user_email, route: '/admin/api/safety-identifiers', @@ -1470,8 +1472,42 @@ describe('getUserFromAuth admin_access telemetry (REST)', () => { ip: '203.0.113.7', tokenSource: null, }); + expect(events[1]).toMatchObject({ + kind: 'privileged_access', + credential: 'human_bearer', + outcome: 'succeeded', + actionClass: 'sensitive_write', + route: '/admin/api/safety-identifiers', + kiloUserId: admin.id, + }); }); + test.each([ + ['delegated workload', { tokenSource: 'cloud-agent' }, 'delegated_workload'], + ['service assertion', { internalApiUse: true }, 'service_assertion'], + ] as const)( + 'preserves %s provenance on a denied admin request', + async (_label, extra, expectedCredential) => { + const user = await insertTestUser({ + google_user_email: `denied-${crypto.randomUUID()}@kilocode.ai`, + api_token_pepper: `denied-${crypto.randomUUID()}`, + }); + mockHeaders.mockResolvedValue( + new Headers({ Authorization: `Bearer ${generateApiToken(user, extra)}` }) + ); + + const result = await getUserFromAuth({ adminOnly: true }); + + expect(result.authFailedResponse).not.toBeNull(); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + kind: 'privileged_access', + credential: expectedCredential, + outcome: 'denied', + }); + } + ); + test('does not emit when a non-admin token hits an admin-only check', async () => { const nonAdmin = await insertTestUser({ is_admin: false, @@ -1483,7 +1519,13 @@ describe('getUserFromAuth admin_access telemetry (REST)', () => { const result = await getUserFromAuth({ adminOnly: true }); expect(result.authFailedResponse).not.toBeNull(); - expect(events).toHaveLength(0); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + kind: 'privileged_access', + credential: 'human_bearer', + outcome: 'denied', + kiloUserId: null, + }); }); test('does not emit for adminOnly:false calls even for an admin', async () => { diff --git a/packages/web-shared/src/lib/user/server.ts b/packages/web-shared/src/lib/user/server.ts index 899a46ee610..f3d4af1b630 100644 --- a/packages/web-shared/src/lib/user/server.ts +++ b/packages/web-shared/src/lib/user/server.ts @@ -40,8 +40,13 @@ import { authViaTokenFromHeaders, clientIpFromHeaders, emitAdminAccessEvent, + emitPrivilegedAccessEvent, + methodFromHeaders, routeFromHeaders, + userAgentProductFromHeaders, } from '@kilocode/web-shared/lib/admin/admin-access-log'; +import { deriveCredentialKind } from '@kilocode/web-shared/lib/admin/privileged-access'; +import { actionClassForRoute } from '@kilocode/web-shared/lib/admin/privileged-inventory'; import { z } from 'zod'; import { v5 as uuidv5 } from 'uuid'; import { isWebSessionCurrent } from '@kilocode/web-shared/lib/web-session-revocation'; @@ -148,6 +153,8 @@ export type RequiredPermissions = { expectedAudience?: string; // Derive from an actual Request URL or fixed route path, never a request header. requestPathname?: string; + /** The actual Request method when the caller has one; preferred over headers. */ + requestMethod?: string; requestHeaders?: Headers; }; @@ -166,12 +173,12 @@ type GetAuthResponse = */ credentialsRejected?: boolean; isNewUser?: undefined; - organizationId?: undefined; - internalApiUse?: undefined; - botId?: undefined; - tokenSource?: undefined; - deviceSessionId?: undefined; - cloudAgentAuthority?: undefined; + organizationId?: Organization['id']; + internalApiUse?: boolean; + botId?: string; + tokenSource?: string; + deviceSessionId?: string; + cloudAgentAuthority?: CloudAgentAuthority; } | { user: User; @@ -200,9 +207,46 @@ export async function getUserFromAuth(opts: RequiredPermissions): Promise { if (!user) { - return authError(401, 'User not found', kiloUserId); + return authError(401, 'User not found', kiloUserId, { + internalApiUse, + tokenSource, + cloudAgentAuthority, + }); } else if (await isUserBlacklistedByDomain(user)) { - return authError(403, 'Access denied (R0)', kiloUserId); + return authError(403, 'Access denied (R0)', kiloUserId, { + internalApiUse, + tokenSource, + cloudAgentAuthority, + }); } else if (!opts.DANGEROUS_allowBlockedUsers && user.blocked_reason) { - return report_blocked_user(kiloUserId); + return authError(403, 'Access denied (R1)', kiloUserId, { + internalApiUse, + tokenSource, + cloudAgentAuthority, + }); } else if (opts.adminOnly && !user.is_admin) { - return authError(403, 'Access denied (nonadmin)', kiloUserId); + return authError(403, 'Access denied (nonadmin)', kiloUserId, { + internalApiUse, + tokenSource, + cloudAgentAuthority, + }); } // A credential bound to a device session is only valid while that session is @@ -514,6 +599,9 @@ async function validateUserAuthorization({ // not degrade into an anonymous request. return authError(401, 'Device session is no longer active', kiloUserId, { credentialsRejected: true, + internalApiUse, + tokenSource, + cloudAgentAuthority, }); } } @@ -521,11 +609,19 @@ async function validateUserAuthorization({ if (organizationId) { const uuidResult = uuid.safeParse(organizationId); if (!uuidResult.success) { - return authError(400, 'Invalid organization ID format', kiloUserId); + return authError(400, 'Invalid organization ID format', kiloUserId, { + internalApiUse, + tokenSource, + cloudAgentAuthority, + }); } const isMember = await isOrganizationMember(fromDb, organizationId, kiloUserId); if (!isMember) { - return authError(403, 'Access denied (not a member of the organization)', kiloUserId); + return authError(403, 'Access denied (not a member of the organization)', kiloUserId, { + internalApiUse, + tokenSource, + cloudAgentAuthority, + }); } } diff --git a/packages/web-shared/src/routers/organizations/utils.test.ts b/packages/web-shared/src/routers/organizations/utils.test.ts index 87778ddfb8e..b2ea829a39e 100644 --- a/packages/web-shared/src/routers/organizations/utils.test.ts +++ b/packages/web-shared/src/routers/organizations/utils.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from '@jest/globals'; +import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals'; import { ensureOrganizationAccess, ensureOrganizationAccessAndFetchOrg, @@ -76,8 +76,9 @@ describe('organization access kilo_admin_elevation telemetry', () => { // The fetch helper attributes the attempt before resolving it; the // access helper records nothing because no elevation was granted. - expect(events).toHaveLength(1); - expect(events[0]).toMatchObject({ reason: 'organization_fetch' }); + const elevationEvents = events.filter(event => event.kind === 'kilo_admin_elevation'); + expect(elevationEvents).toHaveLength(1); + expect(elevationEvents[0]).toMatchObject({ reason: 'organization_fetch' }); }); test('a batched admin resolution elevates only live organizations', async () => { @@ -96,8 +97,9 @@ describe('organization access kilo_admin_elevation telemetry', () => { expect(roles.get(active.id)).toBe('owner'); expect(roles.get(alsoActive.id)).toBe('owner'); expect(roles.has(deleted.id)).toBe(false); - expect(events).toHaveLength(1); - expect(events[0]).toMatchObject({ + const elevationEvents = events.filter(event => event.kind === 'kilo_admin_elevation'); + expect(elevationEvents).toHaveLength(1); + expect(elevationEvents[0]).toMatchObject({ reason: 'organization_access_batch', target: 'organizations:2', }); @@ -133,8 +135,9 @@ describe('organization access kilo_admin_elevation telemetry', () => { await expect(ensureOrganizationAccess(ctxFor(admin), organization.id)).resolves.toBe('owner'); - expect(events).toHaveLength(1); - expect(events[0]).toMatchObject({ + const elevationEvents = events.filter(event => event.kind === 'kilo_admin_elevation'); + expect(elevationEvents).toHaveLength(1); + expect(elevationEvents[0]).toMatchObject({ event: 'admin_access', surface: 'trpc', kind: 'kilo_admin_elevation', @@ -160,8 +163,9 @@ describe('organization access kilo_admin_elevation telemetry', () => { await expect(ensureOrganizationAccess({ user: admin }, organization.id)).resolves.toBe('owner'); - expect(events).toHaveLength(1); - expect(events[0]).toMatchObject({ + const elevationEvents = events.filter(event => event.kind === 'kilo_admin_elevation'); + expect(elevationEvents).toHaveLength(1); + expect(elevationEvents[0]).toMatchObject({ surface: 'rest', kind: 'kilo_admin_elevation', kiloUserId: admin.id, @@ -172,6 +176,65 @@ describe('organization access kilo_admin_elevation telemetry', () => { }); }); + function failMembershipQueries() { + const originalSelect = db.select.bind(db); + return jest.spyOn(db, 'select').mockImplementation((...args) => { + const builder = originalSelect(...args); + return new Proxy(builder, { + get(target, property, receiver) { + if (property === 'from') { + return (table: Parameters[0]) => { + if (table === organization_memberships) { + throw new Error('membership query failed'); + } + return target.from(table); + }; + } + const value = Reflect.get(target, property, receiver); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + }); + } + + test('a membership observation failure never fails the admin request', async () => { + const admin = await insertTestUser({ is_admin: true }); + const organization = await orgOwnedByAnotherUser(); + + const selectSpy = failMembershipQueries(); + try { + await expect(ensureOrganizationAccess(ctxFor(admin), organization.id)).resolves.toBe('owner'); + } finally { + selectSpy.mockRestore(); + } + + expect(events.find(event => event.kind === 'kilo_admin_elevation')).toMatchObject({ + kind: 'kilo_admin_elevation', + membershipRole: null, + wouldPassWithoutAdmin: null, + }); + }); + + test('a membership observation failure never fails the batch admin request', async () => { + const admin = await insertTestUser({ is_admin: true }); + const organization = await orgOwnedByAnotherUser(); + + const selectSpy = failMembershipQueries(); + let roles: Map; + try { + roles = await getOrganizationsAccessRoles(ctxFor(admin), [organization.id]); + } finally { + selectSpy.mockRestore(); + } + + expect(roles.get(organization.id)).toBe('owner'); + expect(events.find(event => event.kind === 'kilo_admin_elevation')).toMatchObject({ + kind: 'kilo_admin_elevation', + membershipRole: null, + wouldPassWithoutAdmin: null, + }); + }); + test('ensureOrganizationAccess stays silent for a member resolving their own org', async () => { const member = await insertTestUser({ is_admin: false }); const organization = await createTestOrganization( @@ -198,14 +261,34 @@ describe('organization access kilo_admin_elevation telemetry', () => { expect(roles.get(first.id)).toBe('owner'); expect(roles.get(second.id)).toBe('owner'); - expect(events).toHaveLength(1); - expect(events[0]).toMatchObject({ + const elevationEvents = events.filter(event => event.kind === 'kilo_admin_elevation'); + expect(elevationEvents).toHaveLength(1); + expect(elevationEvents[0]).toMatchObject({ kind: 'kilo_admin_elevation', reason: 'organization_access_batch', target: 'organizations:2', }); }); + test('batch observation honors required roles instead of any membership', async () => { + const admin = await insertTestUser({ is_admin: true }); + const organization = await orgOwnedByAnotherUser(); + await db.insert(organization_memberships).values({ + organization_id: organization.id, + kilo_user_id: admin.id, + role: 'member', + }); + + await getOrganizationsAccessRoles(ctxFor(admin), [organization.id], ['owner']); + + expect(events.find(event => event.kind === 'kilo_admin_elevation')).toMatchObject({ + kind: 'kilo_admin_elevation', + reason: 'organization_access_batch', + membershipRole: null, + wouldPassWithoutAdmin: false, + }); + }); + test('ensureOrganizationAccessAndFetchOrg records the org an admin fetched', async () => { const admin = await insertTestUser({ is_admin: true }); const organization = await orgOwnedByAnotherUser(); @@ -213,8 +296,9 @@ describe('organization access kilo_admin_elevation telemetry', () => { const fetched = await ensureOrganizationAccessAndFetchOrg(ctxFor(admin), organization.id); expect(fetched.id).toBe(organization.id); - expect(events).toHaveLength(1); - expect(events[0]).toMatchObject({ + const elevationEvents = events.filter(event => event.kind === 'kilo_admin_elevation'); + expect(elevationEvents).toHaveLength(1); + expect(elevationEvents[0]).toMatchObject({ kind: 'kilo_admin_elevation', reason: 'organization_fetch', target: `organization:${organization.id}`, @@ -229,8 +313,9 @@ describe('organization access kilo_admin_elevation telemetry', () => { /Organization not found/ ); - expect(events).toHaveLength(1); - expect(events[0]).toMatchObject({ + const elevationEvents = events.filter(event => event.kind === 'kilo_admin_elevation'); + expect(elevationEvents).toHaveLength(1); + expect(elevationEvents[0]).toMatchObject({ reason: 'organization_fetch', target: `organization:${missingId}`, }); diff --git a/packages/web-shared/src/routers/organizations/utils.ts b/packages/web-shared/src/routers/organizations/utils.ts index c8bbb6e3425..f07590d3439 100644 --- a/packages/web-shared/src/routers/organizations/utils.ts +++ b/packages/web-shared/src/routers/organizations/utils.ts @@ -90,39 +90,40 @@ async function selectCallerRoleRows( organizationFilter: OrganizationIdFilter, fields: SelectedFields ): Promise { - const directRows = await db - .select(fields) - .from(organization_memberships) - .innerJoin( - organizations, - activeOrganizationJoinCondition(organization_memberships.organization_id) - ) - .where( - and( - eq(organization_memberships.kilo_user_id, kiloUserId), - organizationFilter(organization_memberships.organization_id) + const [directRows, inheritedRows] = await Promise.all([ + db + .select(fields) + .from(organization_memberships) + .innerJoin( + organizations, + activeOrganizationJoinCondition(organization_memberships.organization_id) ) - ); - - const inheritedRows = await db - .select(fields) - .from(organizations) - .innerJoin( - parentOrganizations, - and( - eq(parentOrganizations.id, organizations.parent_organization_id), - isNull(parentOrganizations.deleted_at) + .where( + and( + eq(organization_memberships.kilo_user_id, kiloUserId), + organizationFilter(organization_memberships.organization_id) + ) + ), + db + .select(fields) + .from(organizations) + .innerJoin( + parentOrganizations, + and( + eq(parentOrganizations.id, organizations.parent_organization_id), + isNull(parentOrganizations.deleted_at) + ) ) - ) - .innerJoin( - organization_memberships, - and( - eq(organization_memberships.organization_id, organizations.parent_organization_id), - eq(organization_memberships.kilo_user_id, kiloUserId), - inArray(organization_memberships.role, ORGANIZATION_PARENT_ACCESS_ROLES) + .innerJoin( + organization_memberships, + and( + eq(organization_memberships.organization_id, organizations.parent_organization_id), + eq(organization_memberships.kilo_user_id, kiloUserId), + inArray(organization_memberships.role, ORGANIZATION_PARENT_ACCESS_ROLES) + ) ) - ) - .where(and(organizationFilter(organizations.id), isNull(organizations.deleted_at))); + .where(and(organizationFilter(organizations.id), isNull(organizations.deleted_at))), + ]); return [...directRows, ...inheritedRows]; } @@ -140,6 +141,50 @@ function allowedRole( ); } +export function settle( + promise: Promise +): Promise<{ ok: true; value: T } | { ok: false; error: unknown }> { + return promise.then( + value => ({ ok: true as const, value }), + error => ({ ok: false as const, error }) + ); +} + +export async function observeMembershipRole( + loader: () => Promise<{ role: OrganizationRole }[]>, + roles?: OrganizationRole[] +): Promise<{ + membershipRole: OrganizationRole | null; + wouldPassWithoutAdmin: boolean | null; +}> { + try { + const rows = await loader(); + return { + membershipRole: allowedRole(rows), + wouldPassWithoutAdmin: allowedRole(rows, roles) !== null, + }; + } catch { + return { membershipRole: null, wouldPassWithoutAdmin: null }; + } +} + +async function observeBatchMembershipRoles( + loader: () => Promise> +): Promise | null> { + try { + return await loader(); + } catch { + return null; + } +} + +export async function loadEffectiveMembershipRows( + userId: string, + organizationId: Organization['id'] +): Promise { + return selectCallerRoleRows(userId, column => eq(column, organizationId), callerRoleFields); +} + export async function ensureOrganizationAccess( ctx: TRPCContext, organizationId: Organization['id'], @@ -149,22 +194,38 @@ export async function ensureOrganizationAccess( // An admin elevation applies only to an organization that exists and is // live, matching getOrganizationById. A rejected request is not recorded // as an elevation. - const [organization] = await db - .select({ id: organizations.id }) - .from(organizations) - .where(and(eq(organizations.id, organizationId), isNull(organizations.deleted_at))) - .limit(1); - if (!organization) { + const organizationResult = settle( + db + .select({ id: organizations.id }) + .from(organizations) + .where(and(eq(organizations.id, organizationId), isNull(organizations.deleted_at))) + .limit(1) + ); + const observation = await observeMembershipRole( + () => loadEffectiveMembershipRows(ctx.user.id, organizationId), + roles + ); + const rowsResult = await organizationResult; + if (!rowsResult.ok) { + await recordKiloAdminElevation(ctx, { + reason: 'organization_access', + target: organizationTarget(organizationId), + ...observation, + }); + throw rowsResult.error; + } + if (!rowsResult.value[0]) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Organization not found', }); } - return await elevateViaKiloAdmin(ctx, { + await recordKiloAdminElevation(ctx, { reason: 'organization_access', target: organizationTarget(organizationId), - grant: 'owner', + ...observation, }); + return 'owner'; } const rows = await selectCallerRoleRows( ctx.user.id, @@ -202,9 +263,20 @@ export async function ensureOrganizationAccess( * roles inherited from a parent org (owner/billing_manager). Orgs the caller has * no access to are simply absent from the returned map. */ +function groupRolesByOrganization(rows: CallerRoleRow[]): Map { + const rolesByOrg = new Map(); + for (const row of rows) { + const list = rolesByOrg.get(row.organizationId) ?? []; + list.push(row.role); + rolesByOrg.set(row.organizationId, list); + } + return rolesByOrg; +} + export async function getOrganizationsAccessRoles( ctx: TRPCContext, - organizationIds: Organization['id'][] + organizationIds: Organization['id'][], + roles?: OrganizationRole[] ): Promise> { const uniqueIds = Array.from(new Set(organizationIds)); const result = new Map(); @@ -214,12 +286,22 @@ export async function getOrganizationsAccessRoles( if (ctx.user.is_admin) { // Only organizations that exist and are live are elevated, matching // getOrganizationById. - const liveIds = ( - await db + const [liveOrgRows, rolesByOrg] = await Promise.all([ + db .select({ id: organizations.id }) .from(organizations) - .where(and(inArray(organizations.id, uniqueIds), isNull(organizations.deleted_at))) - ).map(row => row.id); + .where(and(inArray(organizations.id, uniqueIds), isNull(organizations.deleted_at))), + observeBatchMembershipRoles(async () => + groupRolesByOrganization( + await selectCallerRoleRows( + ctx.user.id, + column => inArray(column, uniqueIds), + callerRoleFields + ) + ) + ), + ]); + const liveIds = liveOrgRows.map(row => row.id); if (liveIds.length === 0) { return result; } @@ -227,23 +309,25 @@ export async function getOrganizationsAccessRoles( reason: 'organization_access_batch', target: organizationsTarget(liveIds), grant: 'owner', + membershipRole: null, + wouldPassWithoutAdmin: + rolesByOrg === null + ? null + : liveIds.every( + id => + allowedRole( + (rolesByOrg.get(id) ?? []).map(role => ({ role })), + roles + ) !== null + ), }); for (const id of liveIds) result.set(id, grant); return result; } - const rows = await selectCallerRoleRows( - ctx.user.id, - column => inArray(column, uniqueIds), - callerRoleFields + const rolesByOrg = groupRolesByOrganization( + await selectCallerRoleRows(ctx.user.id, column => inArray(column, uniqueIds), callerRoleFields) ); - - const rolesByOrg = new Map(); - for (const row of rows) { - const list = rolesByOrg.get(row.organizationId) ?? []; - list.push(row.role); - rolesByOrg.set(row.organizationId, list); - } for (const [organizationId, roles] of rolesByOrg) { const best = rolePriority.find(role => roles.includes(role)); if (best) result.set(organizationId, best); @@ -260,7 +344,7 @@ export async function ensureOrganizationsAccess( organizationIds: Organization['id'][], roles?: OrganizationRole[] ): Promise { - const accessByOrg = await getOrganizationsAccessRoles(ctx, organizationIds); + const accessByOrg = await getOrganizationsAccessRoles(ctx, organizationIds, roles); const allowedRoles = roles && roles.length > 0 ? roles : rolePriority; for (const organizationId of new Set(organizationIds)) { const role = accessByOrg.get(organizationId); @@ -279,17 +363,24 @@ export async function ensureOrganizationAccessAndFetchOrg( roles?: OrganizationRole[] ): Promise { if (ctx.user.is_admin) { - // Recorded before the fetch so an admin probing a non-existent organization - // is still attributed, rather than vanishing into the NOT_FOUND below. + const organizationResult = settle( + db + .select() + .from(organizations) + .where(and(eq(organizations.id, organizationId), isNull(organizations.deleted_at))) + ); + const observation = await observeMembershipRole( + () => loadEffectiveMembershipRows(ctx.user.id, organizationId), + roles + ); await recordKiloAdminElevation(ctx, { reason: 'organization_fetch', target: organizationTarget(organizationId), + ...observation, }); - const [org] = await db - .select() - .from(organizations) - .where(and(eq(organizations.id, organizationId), isNull(organizations.deleted_at))); - + const rowsResult = await organizationResult; + if (!rowsResult.ok) throw rowsResult.error; + const [org] = rowsResult.value; if (!org) { throw new TRPCError({ code: 'NOT_FOUND',