feat(auth): add view as user and role functionality for owners

Allow owners to impersonate users or roles for debugging permissions.
Add view-as targets to user sessions and resolve effective permissions.
Display a persistent banner in the app shell to exit view-as mode.
This commit is contained in:
2026-05-07 20:31:52 +08:00
parent ee054dcd15
commit 02db73aa4e
12 changed files with 411 additions and 3 deletions

View File

@@ -724,10 +724,21 @@ CREATE TABLE IF NOT EXISTS user_sessions (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash text NOT NULL UNIQUE,
view_as_user_id integer REFERENCES users(id) ON DELETE SET NULL,
view_as_role_id integer REFERENCES roles(id) ON DELETE SET NULL,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT user_sessions_view_as_single_target_check CHECK (view_as_user_id IS NULL OR view_as_role_id IS NULL)
);
ALTER TABLE user_sessions
ADD COLUMN IF NOT EXISTS view_as_user_id integer REFERENCES users(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS view_as_role_id integer REFERENCES roles(id) ON DELETE SET NULL;
ALTER TABLE user_sessions
DROP CONSTRAINT IF EXISTS user_sessions_view_as_single_target_check,
ADD CONSTRAINT user_sessions_view_as_single_target_check CHECK (view_as_user_id IS NULL OR view_as_role_id IS NULL);
CREATE INDEX IF NOT EXISTS user_sessions_user_id_idx
ON user_sessions(user_id);

View File

@@ -85,6 +85,12 @@ export type AuthUser = {
emailVerified: boolean;
roles: RoleSummary[];
permissions: string[];
viewAs?: ViewAsSummary;
};
export type ViewAsSummary = {
mode: 'user' | 'role';
label: string;
};
export type ReferralSummary = {
@@ -148,6 +154,12 @@ type RolePermissionRow = QueryResultRow & {
permission_id: number;
};
type SessionRow = QueryResultRow & {
user_id: number;
view_as_user_id: number | null;
view_as_role_id: number | null;
};
const roleKeyPattern = /^[a-z][a-z0-9-]{1,63}$/;
const permissionKeyPattern = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
const ownerRoleKey = 'owner';
@@ -555,6 +567,38 @@ async function userPermissions(userId: number, client: DbClient | null = null):
return rows.map((row) => row.key);
}
async function rolePermissions(roleId: number, client: DbClient | null = null): Promise<string[]> {
const rows = await runQuery<QueryResultRow & { key: string }>(
client,
`
SELECT DISTINCT p.key
FROM role_permissions rp
JOIN permissions p ON p.id = rp.permission_id
WHERE rp.role_id = $1
AND p.enabled = true
ORDER BY p.key
`,
[roleId]
);
return rows.map((row) => row.key);
}
async function roleById(roleId: number, client: DbClient | null = null): Promise<RoleSummary | null> {
const role = await runQueryOne<RoleRow>(
client,
`
SELECT id, key, name, description, level, enabled, system_role
FROM roles
WHERE id = $1
AND enabled = true
`,
[roleId]
);
return role ? toRoleSummary(role) : null;
}
async function publicUserById(userId: number, client: DbClient | null = null): Promise<AuthUser | null> {
const user = await runQueryOne<UserRow>(
client,
@@ -1275,9 +1319,66 @@ export async function getUserBySessionToken(token: string): Promise<AuthUser | n
return null;
}
const session = await queryOne<QueryResultRow & { user_id: number }>(
const session = await queryOne<SessionRow>(
`
SELECT s.user_id
SELECT s.user_id, s.view_as_user_id, s.view_as_role_id
FROM user_sessions s
WHERE s.token_hash = $1
AND s.expires_at > now()
`,
[hashToken(token)]
);
if (!session) {
return null;
}
const realUser = await publicUserById(session.user_id);
if (!realUser) {
return null;
}
const realUserCanViewAs = realUser.emailVerified && realUser.roles.some((role) => role.key === ownerRoleKey);
if (realUserCanViewAs && session.view_as_user_id) {
const viewAsUser = await publicUserById(session.view_as_user_id);
if (viewAsUser) {
return {
...viewAsUser,
viewAs: {
mode: 'user',
label: viewAsUser.displayName || viewAsUser.email
}
};
}
}
if (realUserCanViewAs && session.view_as_role_id) {
const role = await roleById(session.view_as_role_id);
if (role) {
return {
...realUser,
roles: [role],
permissions: await rolePermissions(role.id),
viewAs: {
mode: 'role',
label: role.name
}
};
}
}
return realUser;
}
async function realUserBySessionToken(token: string): Promise<AuthUser | null> {
if (token.length < 32) {
return null;
}
const session = await queryOne<SessionRow>(
`
SELECT s.user_id, s.view_as_user_id, s.view_as_role_id
FROM user_sessions s
WHERE s.token_hash = $1
AND s.expires_at > now()
@@ -1288,6 +1389,89 @@ export async function getUserBySessionToken(token: string): Promise<AuthUser | n
return session ? publicUserById(session.user_id) : null;
}
function assertOwnerViewAsUser(user: AuthUser | null): AuthUser {
if (!user || !user.emailVerified || !user.roles.some((role) => role.key === ownerRoleKey)) {
throw statusError('server.permissions.permissionDenied', 403);
}
return user;
}
function cleanViewAsId(value: unknown): number {
const id = Number(value);
if (!Number.isInteger(id) || id <= 0) {
throw statusError('server.permissions.invalidSelection', 400);
}
return id;
}
export async function startViewAsUser(sessionToken: string, payload: Record<string, unknown>): Promise<AuthUser> {
assertOwnerViewAsUser(await realUserBySessionToken(sessionToken));
const targetUserId = cleanViewAsId(payload.userId);
const targetUser = await publicUserById(targetUserId);
if (!targetUser) {
throw statusError('server.permissions.userNotFound', 404);
}
await pool.query(
`
UPDATE user_sessions
SET view_as_user_id = $1,
view_as_role_id = NULL
WHERE token_hash = $2
AND expires_at > now()
`,
[targetUserId, hashToken(sessionToken)]
);
const user = await getUserBySessionToken(sessionToken);
if (!user) {
throw statusError('server.errors.loginRequired', 401);
}
return user;
}
export async function startViewAsRole(sessionToken: string, payload: Record<string, unknown>): Promise<AuthUser> {
assertOwnerViewAsUser(await realUserBySessionToken(sessionToken));
const targetRoleId = cleanViewAsId(payload.roleId);
const role = await roleById(targetRoleId);
if (!role) {
throw statusError('server.permissions.roleNotFound', 404);
}
await pool.query(
`
UPDATE user_sessions
SET view_as_user_id = NULL,
view_as_role_id = $1
WHERE token_hash = $2
AND expires_at > now()
`,
[targetRoleId, hashToken(sessionToken)]
);
const user = await getUserBySessionToken(sessionToken);
if (!user) {
throw statusError('server.errors.loginRequired', 401);
}
return user;
}
export async function stopViewAs(sessionToken: string): Promise<AuthUser> {
const realUser = assertOwnerViewAsUser(await realUserBySessionToken(sessionToken));
await pool.query(
`
UPDATE user_sessions
SET view_as_user_id = NULL,
view_as_role_id = NULL
WHERE token_hash = $1
AND expires_at > now()
`,
[hashToken(sessionToken)]
);
return realUser;
}
export async function updateCurrentUser(
userId: number,
payload: Record<string, unknown>,

View File

@@ -22,6 +22,9 @@ import {
registerUser,
requestPasswordReset,
resetPassword,
startViewAsRole,
startViewAsUser,
stopViewAs,
updateAdminUserRoles,
updateCurrentUser,
updatePermission,
@@ -1089,6 +1092,47 @@ app.get('/api/auth/me', async (request, reply) => {
return { user };
});
app.post('/api/auth/view-as/user', async (request, reply) => {
if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) {
return;
}
const token = getSessionToken(request);
if (!token) {
return reply.code(401).send({ message: await serverMessage(requestLocale(request), 'loginRequired') });
}
const payload = request.body && typeof request.body === 'object' ? (request.body as Record<string, unknown>) : {};
return { user: await startViewAsUser(token, payload) };
});
app.post('/api/auth/view-as/role', async (request, reply) => {
if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) {
return;
}
const token = getSessionToken(request);
if (!token) {
return reply.code(401).send({ message: await serverMessage(requestLocale(request), 'loginRequired') });
}
const payload = request.body && typeof request.body === 'object' ? (request.body as Record<string, unknown>) : {};
return { user: await startViewAsRole(token, payload) };
});
app.post('/api/auth/view-as/stop', async (request, reply) => {
if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) {
return;
}
const token = getSessionToken(request);
if (!token) {
return reply.code(401).send({ message: await serverMessage(requestLocale(request), 'loginRequired') });
}
return { user: await stopViewAs(token) };
});
app.patch('/api/auth/me', async (request, reply) => {
if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) {
return;