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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user