diff --git a/e2e/admin-rbac.spec.ts b/e2e/admin-rbac.spec.ts new file mode 100644 index 0000000000..a2d006ab53 --- /dev/null +++ b/e2e/admin-rbac.spec.ts @@ -0,0 +1,100 @@ +import { expect, test } from './playwright-utils.ts' + +test('admin RBAC controls access, role assignment, and privacy boundaries', async ({ + page, + seedE2eUser, + assignRole, + login, +}) => { + const runId = Date.now() + const adminUser = await seedE2eUser({ + email: `admin-rbac-${runId}@example.com`, + username: `admin-rbac-${runId}`, + password: 'admin-rbac-password', + }) + const memberUser = await seedE2eUser({ + email: `member-rbac-${runId}@example.com`, + username: `member-rbac-${runId}`, + password: 'member-rbac-password', + }) + + await assignRole(adminUser.email, 'admin') + + await login({ + email: memberUser.email, + password: memberUser.password, + mode: 'login', + }) + await page.goto('/') + await page.goto('/admin/users') + await expect(page.getByRole('heading', { name: 'Admin users' })).toBeHidden() + await expect(page.getByText('Forbidden')).toBeVisible() + await expect( + page.getByRole('link', { name: 'Admin', exact: true }), + ).toHaveCount(0) + + const secretResponse = await page.request.post('/account/secrets.json', { + data: { + action: 'save', + name: 'memberPrivateSecret', + scope: 'user', + value: 'super-secret-value', + description: 'Seeded for admin privacy test', + allowedHosts: ['api.example.com'], + allowedCapabilities: [], + allowedPackages: [], + }, + headers: { 'Content-Type': 'application/json' }, + }) + expect(secretResponse.ok()).toBe(true) + + await page.context().clearCookies() + await login({ + email: adminUser.email, + password: adminUser.password, + mode: 'login', + }) + await page.goto('/') + + await expect( + page.getByRole('link', { name: 'Admin', exact: true }), + ).toBeVisible() + await page.goto('/admin/users') + await expect(page.getByRole('heading', { name: 'Admin users' })).toBeVisible() + await expect(page.getByText(memberUser.email)).toBeVisible() + await expect(page.getByText('memberPrivateSecret')).toHaveCount(0) + await expect(page.getByText('super-secret-value')).toHaveCount(0) + + const usersApiResponse = await page.request.get('/admin/users.json') + expect(usersApiResponse.ok()).toBe(true) + const usersPayload = await usersApiResponse.json() + expect(usersPayload.ok).toBe(true) + const memberRecord = usersPayload.users.find( + (user: { email: string }) => user.email === memberUser.email, + ) + expect(memberRecord).toBeTruthy() + expect(Object.keys(memberRecord).sort()).toEqual( + ['created_at', 'email', 'id', 'roles', 'updated_at', 'username'].sort(), + ) + expect(JSON.stringify(memberRecord)).not.toContain('memberPrivateSecret') + expect(JSON.stringify(memberRecord)).not.toContain('super-secret-value') + + await page.getByRole('button', { name: memberUser.username }).click() + await expect(page.getByText('Account metadata only')).toBeVisible() + + const roleSelect = page.getByLabel('Role') + await roleSelect.selectOption('admin') + await page.getByRole('button', { name: 'Assign', exact: true }).click() + await expect(page.getByText('Assigned admin role.')).toBeVisible() + + await page.context().clearCookies() + await login({ + email: memberUser.email, + password: memberUser.password, + mode: 'login', + }) + const sessionResponse = await page.request.get('/session') + expect(sessionResponse.ok()).toBe(true) + const sessionPayload = await sessionResponse.json() + expect(sessionPayload.session.roles).toContain('admin') +}) diff --git a/e2e/d1-utils.ts b/e2e/d1-utils.ts new file mode 100644 index 0000000000..d2ae11d63b --- /dev/null +++ b/e2e/d1-utils.ts @@ -0,0 +1,84 @@ +import { spawnSync } from 'node:child_process' +import path from 'node:path' +import { createPasswordHash } from '@kody-internal/shared/password-hash.ts' + +const projectRoot = path.resolve(import.meta.dirname, '..') + +function quoteSql(value: string) { + return `'${value.replace(/'/g, "''")}'` +} + +export function executeE2eD1Command(sql: string) { + const result = spawnSync( + process.execPath, + [ + '--env-file=packages/worker/.env', + './wrangler-env.ts', + 'd1', + 'execute', + 'APP_DB', + '--local', + '--persist-to', + '.wrangler/state/e2e', + '--command', + sql, + ], + { + cwd: projectRoot, + encoding: 'utf8', + stdio: 'pipe', + env: { + ...process.env, + CLOUDFLARE_ENV: 'test', + }, + }, + ) + + if (result.status !== 0) { + throw new Error( + `Failed to execute E2E D1 command:\n${result.stdout}\n${result.stderr}`, + ) + } +} + +function buildSeedUserSql(input: { + email: string + username: string + passwordHash: string +}) { + return ` +INSERT INTO users (username, email, password_hash) +VALUES (${quoteSql(input.username)}, ${quoteSql(input.email)}, ${quoteSql(input.passwordHash)}) +ON CONFLICT(email) DO UPDATE SET + username = excluded.username, + password_hash = excluded.password_hash, + updated_at = CURRENT_TIMESTAMP; +INSERT OR IGNORE INTO user_roles (user_id, role_id) +SELECT u.id, r.id +FROM users u, roles r +WHERE u.email = ${quoteSql(input.email)} AND r.name = 'user';`.trim() +} + +export async function seedUserInE2eDatabase(input: { + email: string + username: string + password: string +}) { + const passwordHash = await createPasswordHash(input.password) + executeE2eD1Command( + buildSeedUserSql({ + email: input.email, + username: input.username, + passwordHash, + }), + ) +} + +export function assignRoleInE2eDatabase(email: string, role: string) { + const sql = ` +INSERT OR IGNORE INTO user_roles (user_id, role_id) +SELECT u.id, r.id +FROM users u, roles r +WHERE u.email = ${quoteSql(email)} AND r.name = ${quoteSql(role)};`.trim() + executeE2eD1Command(sql) +} diff --git a/e2e/playwright-utils.ts b/e2e/playwright-utils.ts index b7457ab402..5bd95c0af0 100644 --- a/e2e/playwright-utils.ts +++ b/e2e/playwright-utils.ts @@ -1,5 +1,6 @@ import { test as base } from '@playwright/test' import * as setCookieParser from 'set-cookie-parser' +import { assignRoleInE2eDatabase, seedUserInE2eDatabase } from './d1-utils.ts' import { ensurePrimaryUserExists, primaryTestUser } from './auth-test-user.ts' export * from '@playwright/test' @@ -10,10 +11,17 @@ export const test = base.extend<{ username?: string password?: string }): Promise<{ email: string; username: string; password: string }> + assignRole(email: string, role: string): Promise + seedE2eUser(options?: { + email?: string + username?: string + password?: string + }): Promise<{ email: string; username: string; password: string }> login(options?: { email?: string username?: string password?: string + mode?: 'login' | 'signup' }): Promise<{ email: string; username: string; password: string }> }>({ insertNewUser: async ({ page }, use) => { @@ -64,6 +72,21 @@ export const test = base.extend<{ return { email, username, password } }) }, + assignRole: async ({}, use) => { + await use(async (email, role) => { + assignRoleInE2eDatabase(email, role) + }) + }, + seedE2eUser: async ({}, use) => { + await use(async (options) => { + const runId = Date.now() + const email = options?.email ?? `e2e-user-${runId}@example.com` + const username = options?.username ?? `e2e-user-${runId}` + const password = options?.password ?? 'e2e-test-password' + await seedUserInE2eDatabase({ email, username, password }) + return { email, username, password } + }) + }, login: async ({ page }, use) => { await use(async (options) => { const email = options?.email ?? primaryTestUser.email @@ -73,33 +96,47 @@ export const test = base.extend<{ ? primaryTestUser.username : usernameFromEmail(email)) const password = options?.password ?? primaryTestUser.password + const preferredMode = options?.mode - let response = await page.request.post('/auth', { - data: { email, username, password, mode: 'signup' }, - headers: { 'Content-Type': 'application/json' }, - }) - - if (response.status() === 409) { - const detail = await readResponseDetail(response) - if (detail !== 'Email already registered.') { + let response: Awaited> + if (preferredMode === 'login') { + response = await page.request.post('/auth', { + data: { email, password, mode: 'login' }, + headers: { 'Content-Type': 'application/json' }, + }) + if (!response.ok()) { throw new Error( - `Failed to seed user (${response.status()}): ${detail}`, + `Failed to login user (${response.status()}): ${await readResponseDetail(response)}`, ) } + } else { response = await page.request.post('/auth', { - data: { email, password, mode: 'login' }, + data: { email, username, password, mode: 'signup' }, headers: { 'Content-Type': 'application/json' }, }) - if (!response.ok()) { + if (response.status() === 409) { + const detail = await readResponseDetail(response) + if (detail !== 'Email already registered.') { + throw new Error( + `Failed to seed user (${response.status()}): ${detail}`, + ) + } + response = await page.request.post('/auth', { + data: { email, password, mode: 'login' }, + headers: { 'Content-Type': 'application/json' }, + }) + + if (!response.ok()) { + throw new Error( + `Failed to login user (${response.status()}): ${await readResponseDetail(response)}`, + ) + } + } else if (!response.ok()) { throw new Error( - `Failed to login user (${response.status()}): ${await readResponseDetail(response)}`, + `Failed to seed user (${response.status()}): ${await readResponseDetail(response)}`, ) } - } else if (!response.ok()) { - throw new Error( - `Failed to seed user (${response.status()}): ${await readResponseDetail(response)}`, - ) } const setCookieHeader = response.headers()['set-cookie'] diff --git a/packages/worker/client/app.tsx b/packages/worker/client/app.tsx index 3bb1004603..97c9aa48f1 100644 --- a/packages/worker/client/app.tsx +++ b/packages/worker/client/app.tsx @@ -11,6 +11,7 @@ import { type SessionInfo, type SessionStatus, } from './session.ts' +import { userHasRole } from '#app/permissions.ts' import { buildAuthLink } from './auth-links.ts' import { colors, mq, spacing, typography } from './styles/tokens.ts' @@ -93,12 +94,15 @@ export function App(handle: Handle) { currentPathname.startsWith('/account/integrations') || currentPathname.startsWith('/account/package-invocation-tokens') || currentPathname.startsWith('/account/remote-connectors') || - currentPathname.startsWith('/account/secrets') + currentPathname.startsWith('/account/secrets') || + currentPathname.startsWith('/admin') const sessionEmail = session?.email ?? '' const sessionDisplayName = getSessionDisplayName(session) const isSessionReady = sessionStatus === 'ready' const isLoggedIn = isSessionReady && Boolean(sessionEmail) const showAuthLinks = isSessionReady && !isLoggedIn + const showAdminLink = + isLoggedIn && session != null && userHasRole(session, 'admin') const oauthRedirectTo = typeof window !== 'undefined' && currentPathname === '/oauth/authorize' ? `${currentPathname}${window.location.search}` @@ -196,6 +200,11 @@ export function App(handle: Handle) { Connectors + {showAdminLink ? ( + + Admin + + ) : null}
+ + Page {page} of {totalPages} + + + + ) : null} + + } + > +
+ {selectedUser ? ( + <> +
+

+ {selectedUser.username} +

+

+ Account metadata only — no secrets, packages, or other user + content appears on this page. +

+
+ 0 + ? selectedUser.roles.join(', ') + : 'None', + }, + { + label: 'Created', + value: formatTimestamp(selectedUser.created_at), + }, + { + label: 'Updated', + value: formatTimestamp(selectedUser.updated_at), + }, + ]} + /> + +
+ + + +
+
+ + ) : ( +

+ Choose an account from the list to review metadata and roles. +

+ )} +
+ + + ) + } +} diff --git a/packages/worker/client/routes/index.tsx b/packages/worker/client/routes/index.tsx index c58e80edaa..c41fd29e93 100644 --- a/packages/worker/client/routes/index.tsx +++ b/packages/worker/client/routes/index.tsx @@ -3,6 +3,8 @@ import { AccountIntegrationsRoute } from './account-integrations.tsx' import { AccountPackageInvocationTokensRoute } from './account-package-invocation-tokens.tsx' import { AccountRemoteConnectorsRoute } from './account-remote-connectors.tsx' import { AccountSecretsRoute } from './account-secrets.tsx' +import { AdminRolesRoute } from './admin-roles.tsx' +import { AdminUsersRoute } from './admin-users.tsx' import { ConnectOauthRoute } from './connect-oauth.tsx' import { HomeRoute } from './home.tsx' import { LoginRoute } from './login.tsx' @@ -29,6 +31,9 @@ export const clientRoutes = { '/account/secrets/user/:secretName': , '/account/secrets/app/:appId/:secretName': , '/account/secrets/session/:sessionId/:secretName': , + '/admin': , + '/admin/users': , + '/admin/roles': , '/login': , '/signup': , '/reset-password': , diff --git a/packages/worker/src/app/audit-log.ts b/packages/worker/src/app/audit-log.ts index 1f168e4612..7c30b3b371 100644 --- a/packages/worker/src/app/audit-log.ts +++ b/packages/worker/src/app/audit-log.ts @@ -1,7 +1,7 @@ import { toHex } from '@kody-internal/shared/hex.ts' type AuditEvent = { - category: 'account' | 'auth' | 'oauth' + category: 'account' | 'admin' | 'auth' | 'oauth' action: string result: 'success' | 'failure' | 'rate_limited' email?: string diff --git a/packages/worker/src/app/handlers/admin-roles.node.test.ts b/packages/worker/src/app/handlers/admin-roles.node.test.ts new file mode 100644 index 0000000000..28f893a823 --- /dev/null +++ b/packages/worker/src/app/handlers/admin-roles.node.test.ts @@ -0,0 +1,112 @@ +import { expect, test, vi } from 'vitest' +import { type PermissionString, type RoleName } from '#app/permissions.ts' + +const mockModule = vi.hoisted(() => ({ + readAuthenticatedAppUser: vi.fn(), +})) + +vi.mock('#app/authenticated-user.ts', () => ({ + readAuthenticatedAppUser: (...args: Array) => + mockModule.readAuthenticatedAppUser(...args), +})) + +function createAdminActor(roles: Array) { + const permissions: Array = roles.includes('admin') + ? ['read:role:any'] + : ['read:role:own'] + return { + sessionUserId: '1', + userId: 1, + email: 'admin@example.com', + username: 'admin-user', + displayName: 'admin-user', + roles, + permissions, + artifactOwnerIds: ['1'], + mcpUser: { + userId: 'stable-admin', + email: 'admin@example.com', + username: 'admin-user', + displayName: 'admin-user', + }, + } +} + +function createRolesTestEnv() { + const roles = [ + { name: 'admin', description: 'Operator role' }, + { name: 'user', description: 'Default role for every account' }, + ] + const permissions = [ + { role_name: 'user', action: 'read', entity: 'user', access: 'own' }, + { role_name: 'admin', action: 'read', entity: 'role', access: 'any' }, + ] + + return { + COOKIE_SECRET: 'secret', + APP_DB: { + prepare(query: string) { + const normalizedQuery = query.replace(/\s+/g, ' ').trim().toLowerCase() + const execute = { + async all() { + if ( + normalizedQuery.includes('select name, description from roles') + ) { + return { results: roles as Array, meta: { changes: 0 } } + } + if ( + normalizedQuery.includes('from roles r') && + normalizedQuery.includes('join role_permissions rp') + ) { + return { results: permissions as Array, meta: { changes: 0 } } + } + return { results: [] as Array, meta: { changes: 0 } } + }, + async first() { + return null + }, + async run() { + return { meta: { changes: 0 } } + }, + } + return { ...execute, bind: () => execute } + }, + } as unknown as D1Database, + } +} + +const { createAdminRolesApiHandler } = await import('./admin-roles.ts') + +test('admin roles list returns roles and attached permissions', async () => { + mockModule.readAuthenticatedAppUser.mockResolvedValue( + createAdminActor(['admin']), + ) + const handler = createAdminRolesApiHandler( + createRolesTestEnv() as unknown as Env, + ) + const response = await handler.handler({ + request: new Request('https://example.com/admin/roles.json', { + headers: { Accept: 'application/json' }, + }), + params: {}, + url: new URL('https://example.com/admin/roles.json'), + } as never) + expect(response.status).toBe(200) +}) + +test('admin roles API returns 403 without read:role:any permission', async () => { + mockModule.readAuthenticatedAppUser.mockResolvedValue( + createAdminActor(['user']), + ) + const handler = createAdminRolesApiHandler( + createRolesTestEnv() as unknown as Env, + ) + const response = await handler.handler({ + request: new Request('https://example.com/admin/roles.json', { + headers: { Accept: 'application/json' }, + }), + params: {}, + url: new URL('https://example.com/admin/roles.json'), + } as never) + expect(response.status).toBe(403) +}) diff --git a/packages/worker/src/app/handlers/admin-roles.ts b/packages/worker/src/app/handlers/admin-roles.ts new file mode 100644 index 0000000000..2cb5a8eca1 --- /dev/null +++ b/packages/worker/src/app/handlers/admin-roles.ts @@ -0,0 +1,114 @@ +import { type Action } from 'remix/router' +import { readAuthSessionResult } from '#app/auth-session.ts' +import { redirectToLogin } from '#app/auth-redirect.ts' +import { Layout } from '#app/layout.ts' +import { + requireUserWithPermission, + requireUserWithRole, +} from '#app/permissions-server.ts' +import { type PermissionString } from '#app/permissions.ts' +import { render } from '#app/render.ts' +import { type routes } from '#app/routes.ts' + +type AdminRoleListItem = { + name: string + description: string + permissions: Array +} + +type AdminRolesListPayload = { + ok: true + roles: Array +} + +export function createAdminRolesHandler(env: Env) { + return { + middleware: [], + async handler({ request }) { + try { + await requireUserWithRole(request, env, 'admin') + } catch (error) { + if (error instanceof Response) return error + throw error + } + + const { session, setCookie } = await readAuthSessionResult(request) + if (!session) { + return redirectToLogin(request) + } + + const response = render(Layout({ title: 'Admin roles' })) + if (setCookie) { + response.headers.set('Set-Cookie', setCookie) + } + return response + }, + } satisfies Action +} + +export function createAdminRolesApiHandler(env: Env) { + return { + middleware: [], + async handler({ request }) { + try { + await requireUserWithPermission(request, env, 'read:role:any') + const payload = await buildAdminRolesListPayload(env) + return jsonResponse(payload) + } catch (error) { + if (error instanceof Response) { + return error + } + throw error + } + }, + } satisfies Action +} + +async function buildAdminRolesListPayload( + env: Env, +): Promise { + const roleRows = await env.APP_DB.prepare( + `SELECT name, description FROM roles ORDER BY name ASC`, + ).all<{ name: string; description: string }>() + + const permissionRows = await env.APP_DB.prepare( + `SELECT r.name AS role_name, p.action, p.entity, p.access + FROM roles r + INNER JOIN role_permissions rp ON rp.role_id = r.id + INNER JOIN permissions p ON p.id = rp.permission_id + ORDER BY r.name ASC, p.action ASC, p.entity ASC, p.access ASC`, + ).all<{ + role_name: string + action: string + entity: string + access: string + }>() + + const permissionsByRole = new Map>() + for (const row of permissionRows.results ?? []) { + const permission = + `${row.action}:${row.entity}:${row.access}` as PermissionString + const current = permissionsByRole.get(row.role_name) ?? [] + current.push(permission) + permissionsByRole.set(row.role_name, current) + } + + return { + ok: true, + roles: (roleRows.results ?? []).map((row) => ({ + name: row.name, + description: row.description, + permissions: permissionsByRole.get(row.name) ?? [], + })), + } +} + +function jsonResponse(body: Record, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { + 'Cache-Control': 'no-store', + 'Content-Type': 'application/json; charset=utf-8', + }, + }) +} diff --git a/packages/worker/src/app/handlers/admin-users.node.test.ts b/packages/worker/src/app/handlers/admin-users.node.test.ts new file mode 100644 index 0000000000..5bccd4d3d8 --- /dev/null +++ b/packages/worker/src/app/handlers/admin-users.node.test.ts @@ -0,0 +1,321 @@ +import { expect, test, vi } from 'vitest' +import { adminUserListItemFieldNames } from './admin-users.ts' +import { type PermissionString, type RoleName } from '#app/permissions.ts' + +const mockModule = vi.hoisted(() => ({ + readAuthenticatedAppUser: vi.fn(), + logAuditEvent: vi.fn(async () => undefined), +})) + +vi.mock('#app/authenticated-user.ts', () => ({ + readAuthenticatedAppUser: (...args: Array) => + mockModule.readAuthenticatedAppUser(...args), +})) + +vi.mock('#app/audit-log.ts', () => ({ + getRequestIp: () => '127.0.0.1', + logAuditEvent: (...args: Array) => mockModule.logAuditEvent(...args), +})) + +type UserRow = { + id: number + username: string + email: string + created_at: string + updated_at: string +} + +type UserRoleRow = { user_id: number; role_name: RoleName } + +function createAdminActor(roles: Array) { + const permissions: Array = roles.includes('admin') + ? ['read:user:any', 'update:user:any'] + : ['read:user:own'] + return { + sessionUserId: '1', + userId: 1, + email: 'admin@example.com', + username: 'admin-user', + displayName: 'admin-user', + roles, + permissions, + artifactOwnerIds: ['1'], + mcpUser: { + userId: 'stable-admin', + email: 'admin@example.com', + username: 'admin-user', + displayName: 'admin-user', + }, + } +} + +function createAdminTestEnv(input: { + users: Array + userRoles: Array +}) { + const users = new Map(input.users.map((user) => [user.id, { ...user }])) + const userRoles = input.userRoles.map((row) => ({ ...row })) + + return { + COOKIE_SECRET: 'secret', + APP_DB: { + prepare(query: string) { + const normalizedQuery = query.replace(/\s+/g, ' ').trim().toLowerCase() + const execute = { + async all() { + if ( + normalizedQuery.includes('select count(*) as total from users') + ) { + return { + results: [{ total: users.size }] as Array, + meta: { changes: 0 }, + } + } + return { results: [] as Array, meta: { changes: 0 } } + }, + async first() { + if ( + normalizedQuery.includes('select count(*) as total from users') + ) { + return { total: users.size } as T + } + return null + }, + async run() { + return { meta: { changes: 0 } } + }, + } + return { + ...execute, + bind(...params: Array) { + return { + async all() { + if (normalizedQuery.startsWith('select id, username, email')) { + const pageSize = Number(params[0]) + const offset = Number(params[1]) + const results = Array.from(users.values()) + .sort((a, b) => a.id - b.id) + .slice(offset, offset + pageSize) + return { results: results as Array, meta: { changes: 0 } } + } + if (normalizedQuery.includes('where ur.user_id in')) { + const userIds = params.map((value) => Number(value)) + return { + results: userRoles + .filter((row) => userIds.includes(row.user_id)) + .map((row) => ({ + user_id: row.user_id, + role_name: row.role_name, + })) as Array, + meta: { changes: 0 }, + } + } + return { results: [] as Array, meta: { changes: 0 } } + }, + async first() { + if ( + normalizedQuery.includes( + 'select id, email from users where id =', + ) + ) { + const user = users.get(Number(params[0])) + return user ? ({ id: user.id, email: user.email } as T) : null + } + if ( + normalizedQuery.includes( + 'count(distinct ur.user_id) as count', + ) + ) { + const roleName = String(params[0]) + const count = new Set( + userRoles + .filter((row) => row.role_name === roleName) + .map((row) => row.user_id), + ).size + return { count } as T + } + return null + }, + async run() { + if ( + normalizedQuery.includes('insert or ignore into user_roles') + ) { + const userId = Number(params[0]) + const roleName = String(params[1]) as RoleName + if ( + !userRoles.some( + (row) => + row.user_id === userId && row.role_name === roleName, + ) + ) { + userRoles.push({ user_id: userId, role_name: roleName }) + } + return { meta: { changes: 1 } } + } + if (normalizedQuery.includes('delete from user_roles')) { + const userId = Number(params[0]) + const roleName = String(params[1]) as RoleName + const index = userRoles.findIndex( + (row) => + row.user_id === userId && row.role_name === roleName, + ) + if (index >= 0) userRoles.splice(index, 1) + return { meta: { changes: 1 } } + } + return { meta: { changes: 0 } } + }, + } + }, + } + }, + } as unknown as D1Database, + } +} + +const { createAdminUsersApiHandler } = await import('./admin-users.ts') + +test('admin users list payload exposes only account metadata fields', async () => { + mockModule.readAuthenticatedAppUser.mockResolvedValue( + createAdminActor(['admin']), + ) + const env = createAdminTestEnv({ + users: [ + { + id: 1, + username: 'admin-user', + email: 'admin@example.com', + created_at: '2026-01-01 00:00:00', + updated_at: '2026-01-02 00:00:00', + }, + { + id: 2, + username: 'member', + email: 'member@example.com', + created_at: '2026-01-03 00:00:00', + updated_at: '2026-01-04 00:00:00', + }, + ], + userRoles: [ + { user_id: 1, role_name: 'admin' }, + { user_id: 2, role_name: 'user' }, + ], + }) + + const handler = createAdminUsersApiHandler(env as unknown as Env) + const response = await handler.handler({ + request: new Request('https://example.com/admin/users.json', { + headers: { Accept: 'application/json' }, + }), + params: {}, + url: new URL('https://example.com/admin/users.json'), + } as never) + + expect(response.status).toBe(200) + const payload = await response.json() + expect(Object.keys(payload).sort()).toEqual( + ['availableRoles', 'ok', 'page', 'pageSize', 'total', 'users'].sort(), + ) + for (const user of payload.users) { + expect(Object.keys(user).sort()).toEqual( + [...adminUserListItemFieldNames].sort(), + ) + } +}) + +test('assign role action updates user roles and logs audit event', async () => { + mockModule.logAuditEvent.mockClear() + mockModule.readAuthenticatedAppUser.mockResolvedValue( + createAdminActor(['admin']), + ) + const env = createAdminTestEnv({ + users: [ + { + id: 2, + username: 'member', + email: 'member@example.com', + created_at: '2026-01-03 00:00:00', + updated_at: '2026-01-04 00:00:00', + }, + ], + userRoles: [{ user_id: 2, role_name: 'user' }], + }) + + const handler = createAdminUsersApiHandler(env as unknown as Env) + const response = await handler.handler({ + request: new Request('https://example.com/admin/users.json', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + action: 'assign_role', + userId: 2, + role: 'admin', + }), + }), + params: {}, + url: new URL('https://example.com/admin/users.json'), + } as never) + + expect(response.status).toBe(200) + const payload = await response.json() + expect(payload.users[0].roles).toContain('admin') + expect(mockModule.logAuditEvent).toHaveBeenCalledWith( + expect.objectContaining({ category: 'admin', action: 'assign_role' }), + ) +}) + +test('remove role rejects removing the last admin account', async () => { + mockModule.readAuthenticatedAppUser.mockResolvedValue( + createAdminActor(['admin']), + ) + const env = createAdminTestEnv({ + users: [ + { + id: 1, + username: 'solo-admin', + email: 'admin@example.com', + created_at: '2026-01-01 00:00:00', + updated_at: '2026-01-02 00:00:00', + }, + ], + userRoles: [{ user_id: 1, role_name: 'admin' }], + }) + + const handler = createAdminUsersApiHandler(env as unknown as Env) + const response = await handler.handler({ + request: new Request('https://example.com/admin/users.json', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + action: 'remove_role', + userId: 1, + role: 'admin', + }), + }), + params: {}, + url: new URL('https://example.com/admin/users.json'), + } as never) + + expect(response.status).toBe(409) +}) + +test('admin users API returns 403 without read:user:any permission', async () => { + mockModule.readAuthenticatedAppUser.mockResolvedValue( + createAdminActor(['user']), + ) + const env = createAdminTestEnv({ users: [], userRoles: [] }) + const handler = createAdminUsersApiHandler(env as unknown as Env) + const response = await handler.handler({ + request: new Request('https://example.com/admin/users.json', { + headers: { Accept: 'application/json' }, + }), + params: {}, + url: new URL('https://example.com/admin/users.json'), + } as never) + expect(response.status).toBe(403) +}) diff --git a/packages/worker/src/app/handlers/admin-users.ts b/packages/worker/src/app/handlers/admin-users.ts new file mode 100644 index 0000000000..66fbfba806 --- /dev/null +++ b/packages/worker/src/app/handlers/admin-users.ts @@ -0,0 +1,412 @@ +import { type Action } from 'remix/router' +import { getRequestIp, logAuditEvent } from '#app/audit-log.ts' +import { readAuthSessionResult } from '#app/auth-session.ts' +import { redirectToLogin } from '#app/auth-redirect.ts' +import { Layout } from '#app/layout.ts' +import { + assignUserRole, + countUsersWithRole, + removeUserRole, + requireUserWithPermission, + requireUserWithRole, +} from '#app/permissions-server.ts' +import { type RoleName, roleNames } from '#app/permissions.ts' +import { render } from '#app/render.ts' +import { type routes } from '#app/routes.ts' + +export const adminUserListItemFieldNames = [ + 'id', + 'username', + 'email', + 'created_at', + 'updated_at', + 'roles', +] as const + +export type AdminUserListItemFieldName = + (typeof adminUserListItemFieldNames)[number] + +export type AdminUserListItem = Record & { + id: number + username: string + email: string + created_at: string + updated_at: string + roles: Array +} + +type AdminUsersListPayload = { + ok: true + users: Array + page: number + pageSize: number + total: number + availableRoles: Array +} + +const defaultPageSize = 20 +const maxPageSize = 100 + +export function createAdminHandler(env: Env) { + return { + middleware: [], + async handler({ request }) { + try { + await requireUserWithRole(request, env, 'admin') + } catch (error) { + if (error instanceof Response) return error + throw error + } + return Response.redirect(new URL('/admin/users', request.url), 302) + }, + } satisfies Action +} + +export function createAdminUsersHandler(env: Env) { + return { + middleware: [], + async handler({ request }) { + try { + await requireUserWithRole(request, env, 'admin') + } catch (error) { + if (error instanceof Response) return error + throw error + } + + const { session, setCookie } = await readAuthSessionResult(request) + if (!session) { + return redirectToLogin(request) + } + + const response = render(Layout({ title: 'Admin users' })) + if (setCookie) { + response.headers.set('Set-Cookie', setCookie) + } + return response + }, + } satisfies Action +} + +export function createAdminUsersApiHandler(env: Env) { + return { + middleware: [], + async handler({ request, url }) { + try { + if (request.method === 'GET') { + await requireUserWithPermission(request, env, 'read:user:any') + const payload = await buildAdminUsersListPayload({ + env, + request, + }) + return jsonResponse(payload) + } + + if (request.method !== 'POST') { + return jsonResponse({ ok: false, error: 'Method not allowed.' }, 405) + } + + const actor = await requireUserWithPermission( + request, + env, + 'update:user:any', + ) + const body = await request.json().catch(() => null) + if (!body || typeof body !== 'object') { + return jsonResponse( + { ok: false, error: 'Invalid request body.' }, + 400, + ) + } + + const action = readString(body, 'action') + if (action === 'assign_role') { + return handleAssignRoleAction({ + env, + request, + url, + actor, + body, + }) + } + if (action === 'remove_role') { + return handleRemoveRoleAction({ + env, + request, + url, + actor, + body, + }) + } + + return jsonResponse({ ok: false, error: 'Invalid action.' }, 400) + } catch (error) { + if (error instanceof Response) { + return error + } + throw error + } + }, + } satisfies Action +} + +async function buildAdminUsersListPayload(input: { + env: Env + request: Request +}): Promise { + const requestUrl = new URL(input.request.url) + const page = readPositiveInt(requestUrl.searchParams.get('page'), 1) + const pageSize = Math.min( + readPositiveInt(requestUrl.searchParams.get('pageSize'), defaultPageSize), + maxPageSize, + ) + const offset = (page - 1) * pageSize + + const totalResult = await input.env.APP_DB.prepare( + `SELECT COUNT(*) AS total FROM users`, + ).first<{ total: number }>() + const total = totalResult?.total ?? 0 + + const userRows = await input.env.APP_DB.prepare( + `SELECT id, username, email, created_at, updated_at + FROM users + ORDER BY id ASC + LIMIT ? OFFSET ?`, + ) + .bind(pageSize, offset) + .all<{ + id: number + username: string + email: string + created_at: string + updated_at: string + }>() + + const userIds = (userRows.results ?? []).map((row) => row.id) + const rolesByUserId = await loadRolesByUserIds(input.env.APP_DB, userIds) + + return { + ok: true, + users: (userRows.results ?? []).map((row) => + toAdminUserListItem(row, rolesByUserId.get(row.id) ?? []), + ), + page, + pageSize, + total, + availableRoles: [...roleNames], + } +} + +async function loadRolesByUserIds(db: D1Database, userIds: Array) { + const rolesByUserId = new Map>() + if (userIds.length === 0) { + return rolesByUserId + } + + const placeholders = userIds.map(() => '?').join(', ') + const result = await db + .prepare( + `SELECT ur.user_id, r.name AS role_name + FROM user_roles ur + INNER JOIN roles r ON r.id = ur.role_id + WHERE ur.user_id IN (${placeholders}) + ORDER BY ur.user_id ASC, r.name ASC`, + ) + .bind(...userIds) + .all<{ user_id: number; role_name: string }>() + + for (const row of result.results ?? []) { + if (!isRoleName(row.role_name)) continue + const current = rolesByUserId.get(row.user_id) ?? [] + current.push(row.role_name) + rolesByUserId.set(row.user_id, current) + } + + return rolesByUserId +} + +function toAdminUserListItem( + row: { + id: number + username: string + email: string + created_at: string + updated_at: string + }, + roles: Array, +): AdminUserListItem { + return { + id: row.id, + username: row.username, + email: row.email, + created_at: row.created_at, + updated_at: row.updated_at, + roles, + } +} + +async function handleAssignRoleAction(input: { + env: Env + request: Request + url: URL + actor: Awaited> + body: object +}) { + const targetUserId = readPositiveInt(readString(input.body, 'userId'), 0) + const roleName = readRoleName(input.body, 'role') + if (!targetUserId) { + return jsonResponse({ ok: false, error: 'User id is required.' }, 400) + } + if (!roleName) { + return jsonResponse({ ok: false, error: 'Role is required.' }, 400) + } + + const targetUser = await input.env.APP_DB.prepare( + `SELECT id, email FROM users WHERE id = ?`, + ) + .bind(targetUserId) + .first<{ id: number; email: string }>() + if (!targetUser) { + return jsonResponse({ ok: false, error: 'User not found.' }, 404) + } + + await assignUserRole({ + db: input.env.APP_DB, + userId: targetUserId, + roleName, + }) + + const requestIp = getRequestIp(input.request) ?? undefined + void logAuditEvent({ + category: 'admin', + action: 'assign_role', + result: 'success', + email: input.actor.email, + ip: requestIp, + path: input.url.pathname, + reason: `target_user_id=${targetUserId};role=${roleName}`, + }) + + const payload = await buildAdminUsersListPayload({ + env: input.env, + request: input.request, + }) + return jsonResponse(payload) +} + +async function handleRemoveRoleAction(input: { + env: Env + request: Request + url: URL + actor: Awaited> + body: object +}) { + const targetUserId = readPositiveInt(readString(input.body, 'userId'), 0) + const roleName = readRoleName(input.body, 'role') + if (!targetUserId) { + return jsonResponse({ ok: false, error: 'User id is required.' }, 400) + } + if (!roleName) { + return jsonResponse({ ok: false, error: 'Role is required.' }, 400) + } + + const targetUser = await input.env.APP_DB.prepare( + `SELECT id, email FROM users WHERE id = ?`, + ) + .bind(targetUserId) + .first<{ id: number; email: string }>() + if (!targetUser) { + return jsonResponse({ ok: false, error: 'User not found.' }, 404) + } + + if (roleName === 'admin') { + const adminCount = await countUsersWithRole(input.env.APP_DB, 'admin') + const targetRoles = await loadRolesByUserIds(input.env.APP_DB, [ + targetUserId, + ]) + const targetHasAdmin = (targetRoles.get(targetUserId) ?? []).includes( + 'admin', + ) + if (targetHasAdmin && adminCount <= 1) { + const requestIp = getRequestIp(input.request) ?? undefined + void logAuditEvent({ + category: 'admin', + action: 'remove_role', + result: 'failure', + email: input.actor.email, + ip: requestIp, + path: input.url.pathname, + reason: 'last_admin', + }) + return jsonResponse( + { + ok: false, + error: + 'Cannot remove the admin role from the last remaining admin account.', + }, + 409, + ) + } + } + + await removeUserRole({ + db: input.env.APP_DB, + userId: targetUserId, + roleName, + }) + + const requestIp = getRequestIp(input.request) ?? undefined + void logAuditEvent({ + category: 'admin', + action: 'remove_role', + result: 'success', + email: input.actor.email, + ip: requestIp, + path: input.url.pathname, + reason: `target_user_id=${targetUserId};role=${roleName}`, + }) + + const payload = await buildAdminUsersListPayload({ + env: input.env, + request: input.request, + }) + return jsonResponse(payload) +} + +function isRoleName(value: string): value is RoleName { + return (roleNames as ReadonlyArray).includes(value) +} + +function readRoleName(body: object, key: string): RoleName | null { + const value = readString(body, key) + return value && isRoleName(value) ? value : null +} + +function readString(body: object, key: string) { + const value = (body as Record)[key] + if (typeof value === 'string' && value.trim()) { + return value.trim() + } + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value) + } + return null +} + +function readPositiveInt(value: string | null, fallback: number) { + if (!value) return fallback + const parsed = Number.parseInt(value, 10) + if (!Number.isFinite(parsed) || parsed < 1) { + return fallback + } + return parsed +} + +function jsonResponse(body: Record, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { + 'Cache-Control': 'no-store', + 'Content-Type': 'application/json; charset=utf-8', + }, + }) +} diff --git a/packages/worker/src/app/permissions-db.ts b/packages/worker/src/app/permissions-db.ts index d68a994a18..0e8b075791 100644 --- a/packages/worker/src/app/permissions-db.ts +++ b/packages/worker/src/app/permissions-db.ts @@ -55,3 +55,34 @@ export async function assignUserRole(input: { .bind(input.userId, input.roleName) .run() } + +export async function removeUserRole(input: { + db: D1Database + userId: number + roleName: RoleName +}) { + await input.db + .prepare( + `DELETE FROM user_roles + WHERE user_id = ? + AND role_id = (SELECT id FROM roles WHERE name = ?)`, + ) + .bind(input.userId, input.roleName) + .run() +} + +export async function countUsersWithRole( + db: D1Database, + roleName: RoleName, +): Promise { + const result = await db + .prepare( + `SELECT COUNT(DISTINCT ur.user_id) AS count + FROM user_roles ur + INNER JOIN roles r ON r.id = ur.role_id + WHERE r.name = ?`, + ) + .bind(roleName) + .first<{ count: number }>() + return result?.count ?? 0 +} diff --git a/packages/worker/src/app/permissions-server.ts b/packages/worker/src/app/permissions-server.ts index f3353e9b39..a08d026ba7 100644 --- a/packages/worker/src/app/permissions-server.ts +++ b/packages/worker/src/app/permissions-server.ts @@ -13,7 +13,9 @@ import { wantsJson } from '#worker/utils.ts' export { assignUserRole, + countUsersWithRole, getUserRolesAndPermissions, + removeUserRole, } from '#app/permissions-db.ts' export { userHasPermission, userHasRole } from '#app/permissions.ts' diff --git a/packages/worker/src/app/router.ts b/packages/worker/src/app/router.ts index 67737dad8e..7d57b4108b 100644 --- a/packages/worker/src/app/router.ts +++ b/packages/worker/src/app/router.ts @@ -1,4 +1,13 @@ import { createRouter } from 'remix/router' +import { + createAdminHandler, + createAdminUsersApiHandler, + createAdminUsersHandler, +} from '#app/handlers/admin-users.ts' +import { + createAdminRolesApiHandler, + createAdminRolesHandler, +} from '#app/handlers/admin-roles.ts' import { account } from '#app/handlers/account.ts' import { createAccountDeleteHandler } from '#app/handlers/account-delete.ts' import { @@ -112,6 +121,12 @@ export function createAppRouter(appEnv: AppEnv) { accountSecretsApiPost: createAccountSecretsApiHandler( appEnv as unknown as Env, ), + admin: createAdminHandler(appEnv as unknown as Env), + adminUsers: createAdminUsersHandler(appEnv as unknown as Env), + adminUsersApi: createAdminUsersApiHandler(appEnv as unknown as Env), + adminUsersApiPost: createAdminUsersApiHandler(appEnv as unknown as Env), + adminRoles: createAdminRolesHandler(appEnv as unknown as Env), + adminRolesApi: createAdminRolesApiHandler(appEnv as unknown as Env), connectOauth: createConnectOauthHandler(appEnv as unknown as Env), auth: createAuthHandler(appEnv), session: createSessionHandler(appEnv as unknown as Env), diff --git a/packages/worker/src/app/routes.ts b/packages/worker/src/app/routes.ts index 5a4eabc72d..8b3593278f 100644 --- a/packages/worker/src/app/routes.ts +++ b/packages/worker/src/app/routes.ts @@ -28,6 +28,12 @@ export const routes = route({ accountSecretsApiPost: post('/account/secrets.json'), accountProfileApi: '/account/profile.json', accountProfileApiPost: post('/account/profile.json'), + admin: '/admin', + adminUsers: '/admin/users', + adminUsersApi: '/admin/users.json', + adminUsersApiPost: post('/admin/users.json'), + adminRoles: '/admin/roles', + adminRolesApi: '/admin/roles.json', health: '/health', login: '/login', signup: '/signup',