Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions e2e/admin-rbac.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
84 changes: 84 additions & 0 deletions e2e/d1-utils.ts
Original file line number Diff line number Diff line change
@@ -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)
}
69 changes: 53 additions & 16 deletions e2e/playwright-utils.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<void>
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) => {
Expand Down Expand Up @@ -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
Expand All @@ -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<ReturnType<typeof page.request.post>>
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']
Expand Down
11 changes: 10 additions & 1 deletion packages/worker/client/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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}`
Expand Down Expand Up @@ -196,6 +200,11 @@ export function App(handle: Handle) {
<a href="/account/remote-connectors" mix={css(navLinkCss)}>
Connectors
</a>
{showAdminLink ? (
<a href="/admin/users" mix={css(navLinkCss)}>
Admin
</a>
) : null}
<form method="post" action="/logout" mix={css({ margin: 0 })}>
<button type="submit" mix={css(logOutButtonCss)}>
Log out
Expand Down
Loading