multiple groups per user

This commit is contained in:
azykov@mail.ru
2026-09-30 09:06:10 +03:00
parent 45158cde38
commit c197e5978a
21 changed files with 749 additions and 277 deletions
+40 -22
View File
@@ -9,6 +9,7 @@ import {
userCreateSchema,
userPatchSchema,
type Group,
type GroupMembership,
type MeResponse,
} from '@cardy/shared';
import type { CardsService } from '../cards.js';
@@ -16,7 +17,7 @@ import { ADMIN_API_PREFIX } from '../config.js';
import { forwardedPrefix, publicBaseUrl } from '../forwarded.js';
import { badRequest, forbidden, notFound, unauthorized } from '../errors.js';
import { parseBody, readMultipart, sendImage } from '../http.js';
import { requireGroupAdmin, requireSuperadmin, type StoredUser, type UsersService } from '../users.js';
import { requireGroupAdmin, requireSuperadmin, roleIn, type StoredUser, type UsersService } from '../users.js';
export const GROUP_COOKIE = 'cardy_group';
export const CSRF_HEADER = 'x-cardy-admin';
@@ -24,7 +25,8 @@ export const PROXY_SECRET_HEADER = 'x-cardy-proxy-secret';
export interface AdminContext {
user: StoredUser;
ownGroup: Group;
/** The user's groups with their role in each. */
memberships: GroupMembership[];
currentGroup: Group;
}
@@ -75,16 +77,22 @@ export const adminApi: FastifyPluginAsync<Options> = async (app, { users, cards,
if (typeof email !== 'string' || !email.trim()) throw unauthorized('Missing Remote-Email header from the authentication proxy');
const user = await users.findUser(email);
if (!user) throw unauthorized(`User ${email.trim().toLowerCase()} is not registered in Cardy. Ask an administrator for access.`);
const ownGroup = await users.getGroup(user.groupId);
if (!ownGroup) throw unauthorized('User group does not exist');
let currentGroup = ownGroup;
const cookieGroupId = request.cookies[GROUP_COOKIE];
if (user.role === 'superadmin' && cookieGroupId && cookieGroupId !== ownGroup.id) {
currentGroup = (await users.getGroup(cookieGroupId)) ?? ownGroup;
}
request.admin = { user, ownGroup, currentGroup };
const memberships = await users.membershipsOf(user);
if (!memberships.length) throw unauthorized('None of your groups exists');
request.admin = { user, memberships, currentGroup: await resolveCurrentGroup(user, memberships, request.cookies[GROUP_COOKIE]) };
});
/** The group from the cookie if the user may use it (member, or any group for superadmins), else the first own group. */
async function resolveCurrentGroup(user: StoredUser, memberships: GroupMembership[], cookieGroupId: string | undefined): Promise<Group> {
const first = memberships[0]!;
const fallback = { id: first.id, name: first.name };
if (!cookieGroupId) return fallback;
const own = memberships.find((m) => m.id === cookieGroupId);
if (own) return { id: own.id, name: own.name };
if (user.superadmin) return (await users.getGroup(cookieGroupId)) ?? fallback;
return fallback;
}
// Cookie path as the browser sees it, e.g. /api/admin.
const setGroupCookie = (request: FastifyRequest, reply: FastifyReply, groupId: string) =>
reply.setCookie(GROUP_COOKIE, groupId, {
@@ -104,9 +112,15 @@ export const adminApi: FastifyPluginAsync<Options> = async (app, { users, cards,
// ---- current user ----
app.get('/me', async (request, reply): Promise<MeResponse> => {
const { user, ownGroup, currentGroup } = request.admin;
const { user, memberships, currentGroup } = request.admin;
if (request.cookies[GROUP_COOKIE] !== currentGroup.id) setGroupCookie(request, reply, currentGroup.id);
return { email: user.email, role: user.role, group: ownGroup, currentGroup };
return {
email: user.email,
superadmin: user.superadmin,
role: roleIn(user, currentGroup.id) ?? 'user',
groups: memberships,
currentGroup,
};
});
app.get('/my-keys', async (request) => users.keyViews(request.admin.user, apiUrl(request)));
@@ -134,38 +148,42 @@ export const adminApi: FastifyPluginAsync<Options> = async (app, { users, cards,
return reply.code(204).send();
});
/** Any user may switch between their groups; superadmins between all groups. */
app.put('/current-group', async (request, reply) => {
requireSuperadmin(request.admin.user);
const { user, memberships } = request.admin;
const { groupId } = parseBody(currentGroupSchema, request.body);
const group = await users.getGroup(groupId);
if (!group) throw notFound('Group not found');
if (!user.superadmin && !memberships.some((m) => m.id === group.id)) throw forbidden('You are not a member of this group');
setGroupCookie(request, reply, group.id);
return group;
});
// ---- users of the current group ----
// ---- members of the current group ----
app.get('/users', async (request) => {
const { user, groupId } = groupAdmin(request);
return (await users.listUsers(user, groupId)).map((u) => users.userView(u, apiUrl(request)));
const members = await users.listMembers(user, groupId);
return Promise.all(members.map((u) => users.userView(u, groupId, apiUrl(request))));
});
/** Adds a member (an existing user from another group, or a new user). */
app.post('/users', async (request, reply) => {
const { user, groupId } = groupAdmin(request);
const created = await users.createUser(user, groupId, parseBody(userCreateSchema, request.body));
return reply.code(201).send(users.userView(created, apiUrl(request)));
const added = await users.addMember(user, groupId, parseBody(userCreateSchema, request.body));
return reply.code(201).send(await users.userView(added, groupId, apiUrl(request)));
});
app.patch<{ Params: EmailParams }>('/users/:email', async (request) => {
const { user, groupId } = groupAdmin(request);
const updated = await users.updateUser(user, groupId, request.params.email, parseBody(userPatchSchema, request.body));
return users.userView(updated, apiUrl(request));
const updated = await users.updateMember(user, groupId, request.params.email, parseBody(userPatchSchema, request.body));
return users.userView(updated, groupId, apiUrl(request));
});
app.delete<{ Params: EmailParams }>('/users/:email', async (request, reply) => {
/** Removes the member from the current group; a user without other groups is deleted. */
app.delete<{ Params: EmailParams }>('/users/:email', async (request) => {
const { user, groupId } = groupAdmin(request);
await users.deleteUser(user, groupId, request.params.email);
return reply.code(204).send();
return users.removeMember(user, groupId, request.params.email);
});
app.post<{ Params: EmailParams }>('/users/:email/keys', async (request, reply) => {
+40 -27
View File
@@ -1,8 +1,8 @@
import cors, { type FastifyCorsOptions } from '@fastify/cors';
import type { FastifyPluginAsync, FastifyRequest } from 'fastify';
import type { FastifyInstance, FastifyPluginAsync, FastifyRequest } from 'fastify';
import { cardFieldsSchema, type ApiAuthResponse } from '@cardy/shared';
import type { CardsService } from '../cards.js';
import { unauthorized } from '../errors.js';
import { notFound, unauthorized } from '../errors.js';
import { parseBody, readMultipart, sendImage } from '../http.js';
import type { KeyOwner, UsersService } from '../users.js';
@@ -19,8 +19,7 @@ interface Options {
const CLIENT_ACCESS = { includeDeleted: false };
type IdParams = { id: string };
type ImageParams = { id: string; imageId: string };
type Params = Record<string, string>;
export const clientApi: FastifyPluginAsync<Options> = async (app, { users, cards }) => {
// Capacitor apps run on origin https://localhost; auth is a bearer key, never cookies.
@@ -46,50 +45,64 @@ export const clientApi: FastifyPluginAsync<Options> = async (app, { users, cards
request.keyOwner = owner;
});
const groupId = (request: { keyOwner: KeyOwner }) => request.keyOwner.group.id;
app.get('/auth', async (request): Promise<ApiAuthResponse> => {
const { user, group } = request.keyOwner;
return { email: user.email, group };
const { user, groups } = request.keyOwner;
return { email: user.email, groups, group: groups[0]! };
});
app.get('/cards', async (request) => cards.list(groupId(request), CLIENT_ACCESS));
// Cards of a group the key's owner belongs to.
cardRoutes(app, cards, '/groups/:groupId', (request) => {
const groupId = (request.params as Params).groupId;
if (!request.keyOwner.groups.some((g) => g.id === groupId)) throw notFound('Group not found');
return groupId!;
});
// Apps from before multi-group: the owner's first group.
cardRoutes(app, cards, '', (request) => request.keyOwner.groups[0]!.id);
};
app.post('/cards', async (request, reply) => {
function cardRoutes(app: FastifyInstance, cards: CardsService, prefix: string, groupOf: (request: FastifyRequest) => string) {
const param = (request: FastifyRequest, name: string) => (request.params as Params)[name]!;
app.get(`${prefix}/cards`, async (request) => cards.list(groupOf(request), CLIENT_ACCESS));
app.post(`${prefix}/cards`, async (request, reply) => {
const groupId = groupOf(request);
const { fields, files } = await readMultipart(request);
const card = await cards.create(groupId(request), parseBody(cardFieldsSchema, fields), files.get('logo')?.[0]);
const card = await cards.create(groupId, parseBody(cardFieldsSchema, fields), files.get('logo')?.[0]);
return reply.code(201).send(card);
});
app.put<{ Params: IdParams }>('/cards/:id', async (request) => {
app.put(`${prefix}/cards/:id`, async (request) => {
const groupId = groupOf(request);
const { fields, files } = await readMultipart(request);
const { removeLogo, ...cardFields } = fields;
const patch = parseBody(cardFieldsSchema.partial(), cardFields);
const logo = files.get('logo')?.[0] ?? (removeLogo === 'true' ? null : undefined);
return cards.update(groupId(request), request.params.id, patch, CLIENT_ACCESS, logo);
return cards.update(groupId, param(request, 'id'), patch, CLIENT_ACCESS, logo);
});
app.delete<{ Params: IdParams }>('/cards/:id', async (request, reply) => {
const card = await cards.get(groupId(request), request.params.id, CLIENT_ACCESS);
await cards.setDeleted(groupId(request), card.id, true);
app.delete(`${prefix}/cards/:id`, async (request, reply) => {
const groupId = groupOf(request);
const card = await cards.get(groupId, param(request, 'id'), CLIENT_ACCESS);
await cards.setDeleted(groupId, card.id, true);
return reply.code(204).send();
});
app.get<{ Params: IdParams }>('/cards/:id/logo', async (request, reply) =>
sendImage(reply, await cards.imagePath(groupId(request), request.params.id, 'logo', CLIENT_ACCESS)),
app.get(`${prefix}/cards/:id/logo`, async (request, reply) =>
sendImage(reply, await cards.imagePath(groupOf(request), param(request, 'id'), 'logo', CLIENT_ACCESS)),
);
app.get<{ Params: ImageParams }>('/cards/:id/images/:imageId', async (request, reply) => {
const { id, imageId } = request.params;
return sendImage(reply, await cards.imagePath(groupId(request), id, imageId, CLIENT_ACCESS));
});
app.get(`${prefix}/cards/:id/images/:imageId`, async (request, reply) =>
sendImage(reply, await cards.imagePath(groupOf(request), param(request, 'id'), param(request, 'imageId'), CLIENT_ACCESS)),
);
app.post<{ Params: IdParams }>('/cards/:id/images', async (request) => {
app.post(`${prefix}/cards/:id/images`, async (request) => {
const groupId = groupOf(request);
const { files } = await readMultipart(request);
return cards.addImages(groupId(request), request.params.id, files.get('file') ?? [], CLIENT_ACCESS);
return cards.addImages(groupId, param(request, 'id'), files.get('file') ?? [], CLIENT_ACCESS);
});
app.delete<{ Params: ImageParams }>('/cards/:id/images/:imageId', async (request) =>
cards.removeImage(groupId(request), request.params.id, request.params.imageId, CLIENT_ACCESS),
app.delete(`${prefix}/cards/:id/images/:imageId`, async (request) =>
cards.removeImage(groupOf(request), param(request, 'id'), param(request, 'imageId'), CLIENT_ACCESS),
);
};
}
+135 -72
View File
@@ -1,7 +1,16 @@
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import { z } from 'zod';
import { ROLES, buildLoginDeepLink, type AccessKeyView, type Group, type Role, type UserView } from '@cardy/shared';
import {
GROUP_ROLES,
buildLoginDeepLink,
type AccessKeyView,
type Group,
type GroupMembership,
type GroupRole,
type Role,
type UserView,
} from '@cardy/shared';
import { decryptSecret, encryptSecret, generateAccessKey } from './crypto.js';
import { badRequest, conflict, forbidden, notFound } from './errors.js';
import { YamlFile } from './yamlFile.js';
@@ -15,12 +24,29 @@ const storedKeySchema = z.object({
encrypted: z.string(),
});
const storedUserSchema = z.object({
email: z.string().transform((s) => s.trim().toLowerCase()),
groupId: idSchema,
role: z.enum(ROLES),
keys: z.array(storedKeySchema).default([]),
});
const membershipSchema = z.object({ groupId: idSchema, role: z.enum(GROUP_ROLES) });
/** Users from before multi-group ({ groupId, role }) are read as { superadmin, memberships }. */
function upgradeLegacyUser(raw: unknown): unknown {
const u = raw as { groupId?: string; role?: string; memberships?: unknown } | null;
if (!u || typeof u !== 'object' || u.memberships !== undefined || typeof u.groupId !== 'string') return raw;
const { groupId, role, ...rest } = u;
return {
...rest,
superadmin: role === 'superadmin',
memberships: [{ groupId, role: role === 'user' ? 'user' : 'admin' }],
};
}
const storedUserSchema = z.preprocess(
upgradeLegacyUser,
z.object({
email: z.string().transform((s) => s.trim().toLowerCase()),
superadmin: z.boolean().default(false),
memberships: z.array(membershipSchema).min(1, 'A user must belong to at least one group'),
keys: z.array(storedKeySchema).default([]),
}),
);
const usersFileSchema = z.object({
groups: z.array(z.object({ id: idSchema, name: z.string() })).default([]),
@@ -33,7 +59,8 @@ type UsersFile = z.infer<typeof usersFileSchema>;
export interface KeyOwner {
user: StoredUser;
group: Group;
/** The user's groups, in membership order (the first one serves the group-less client routes). */
groups: Group[];
}
export class UsersService {
@@ -52,15 +79,14 @@ export class UsersService {
);
}
/** Creates the configured superadmin (and their group) if missing, and enforces the superadmin role. */
/** Creates the configured superadmin (and their group) if missing, and enforces the superadmin flag. */
async ensureSuperadmin(email: string, groupName: string): Promise<void> {
const data = await this.file.read();
const existing = data.users.find((u) => u.email === email);
if (existing?.role === 'superadmin') return;
if (data.users.find((u) => u.email === email)?.superadmin) return;
await this.file.update((d) => {
const user = d.users.find((u) => u.email === email);
if (user) {
user.role = 'superadmin';
user.superadmin = true;
return;
}
let group = d.groups.find((g) => sameName(g.name, groupName));
@@ -68,7 +94,7 @@ export class UsersService {
group = { id: randomUUID(), name: groupName };
d.groups.push(group);
}
d.users.push({ email, groupId: group.id, role: 'superadmin', keys: [] });
d.users.push({ email, superadmin: true, memberships: [{ groupId: group.id, role: 'admin' }], keys: [] });
});
}
@@ -87,6 +113,15 @@ export class UsersService {
return [...(await this.file.read()).groups].sort((a, b) => a.name.localeCompare(b.name));
}
/** The user's groups with their role in each, in membership order. */
async membershipsOf(user: StoredUser): Promise<GroupMembership[]> {
const { groups } = await this.file.read();
return user.memberships.flatMap((m) => {
const group = groups.find((g) => g.id === m.groupId);
return group ? [{ ...group, role: m.role }] : [];
});
}
async authenticateKey(key: string): Promise<KeyOwner | undefined> {
const data = await this.file.read();
if (this.keyIndex?.source !== data) {
@@ -98,8 +133,9 @@ export class UsersService {
}
const email = this.keyIndex.map.get(key);
const user = email ? data.users.find((u) => u.email === email) : undefined;
const group = user ? data.groups.find((g) => g.id === user.groupId) : undefined;
return user && group ? { user, group } : undefined;
if (!user) return undefined;
const groups = (await this.membershipsOf(user)).map(({ id, name }) => ({ id, name }));
return groups.length ? { user, groups } : undefined;
}
// ---- views ----
@@ -112,8 +148,15 @@ export class UsersService {
});
}
userView(user: StoredUser, apiUrl: string): UserView {
return { email: user.email, groupId: user.groupId, role: user.role, keys: this.keyViews(user, apiUrl) };
/** A member of `groupId`, as shown to the admins of that group. */
async userView(user: StoredUser, groupId: string, apiUrl: string): Promise<UserView> {
return {
email: user.email,
superadmin: user.superadmin,
role: membership(user, groupId)?.role ?? 'user',
groups: await this.membershipsOf(user),
keys: this.keyViews(user, apiUrl),
};
}
// ---- groups (superadmin) ----
@@ -139,86 +182,95 @@ export class UsersService {
});
}
/** Removes the group with its users and keys. The caller removes the group's cards. */
/**
* Removes the group and its memberships. Refused while the group is the only one of some user
* (users always belong to at least one group). The caller removes the group's cards.
*/
async deleteGroup(actor: StoredUser, id: string): Promise<void> {
requireSuperadmin(actor);
await this.file.update((d) => {
if (!d.groups.some((g) => g.id === id)) throw notFound('Group not found');
if (d.users.some((u) => u.groupId === id && u.role === 'superadmin')) {
throw conflict('A group that contains a superadmin cannot be deleted');
const onlyGroupOf = d.users.filter((u) => u.memberships.length === 1 && u.memberships[0]!.groupId === id);
if (onlyGroupOf.length) {
const list = onlyGroupOf.map((u) => u.email).join(', ');
throw conflict(`The group is the only group of ${list}. Add them to another group or remove them first.`);
}
d.groups = d.groups.filter((g) => g.id !== id);
d.users = d.users.filter((u) => u.groupId !== id);
for (const u of d.users) u.memberships = u.memberships.filter((m) => m.groupId !== id);
});
}
// ---- users (admin of the current group) ----
// ---- members of the current group (its admins) ----
async listUsers(actor: StoredUser, groupId: string): Promise<StoredUser[]> {
async listMembers(actor: StoredUser, groupId: string): Promise<StoredUser[]> {
requireGroupAdmin(actor, groupId);
const data = await this.file.read();
return data.users.filter((u) => u.groupId === groupId).sort((a, b) => a.email.localeCompare(b.email));
return data.users.filter((u) => membership(u, groupId)).sort((a, b) => a.email.localeCompare(b.email));
}
async createUser(
actor: StoredUser,
currentGroupId: string,
input: { email: string; role: Role; groupId?: string },
): Promise<StoredUser> {
const groupId = input.groupId ?? currentGroupId;
if (groupId !== currentGroupId) requireSuperadmin(actor);
/** Adds a user to the group; creates the user when the email is new. */
async addMember(actor: StoredUser, groupId: string, input: { email: string; role: GroupRole }): Promise<StoredUser> {
requireGroupAdmin(actor, groupId);
if (input.role === 'superadmin') requireSuperadmin(actor);
return this.file.update((d) => {
if (!d.groups.some((g) => g.id === groupId)) throw notFound('Group not found');
if (d.users.some((u) => u.email === input.email)) throw conflict(`User ${input.email} already exists`);
const user: StoredUser = { email: input.email, groupId, role: input.role, keys: [] };
const existing = d.users.find((u) => u.email === input.email);
if (existing) {
if (membership(existing, groupId)) throw conflict(`${input.email} is already in this group`);
existing.memberships.push({ groupId, role: input.role });
return existing;
}
const user: StoredUser = { email: input.email, superadmin: false, memberships: [{ groupId, role: input.role }], keys: [] };
d.users.push(user);
return user;
});
}
async updateUser(
/** Changes the role in the group and/or (superadmins only) the global superadmin flag. */
async updateMember(
actor: StoredUser,
currentGroupId: string,
groupId: string,
email: string,
patch: { role?: Role; groupId?: string },
patch: { role?: GroupRole; superadmin?: boolean },
): Promise<StoredUser> {
if (patch.role === 'superadmin') requireSuperadmin(actor);
if (patch.groupId !== undefined) requireSuperadmin(actor);
if (patch.superadmin !== undefined) requireSuperadmin(actor);
return this.file.update((d) => {
const user = findManagedUser(d, actor, currentGroupId, email);
if (patch.role !== undefined && patch.role !== user.role) {
if (user.email === actor.email) throw badRequest('You cannot change your own role');
if (user.role === 'superadmin' && countSuperadmins(d) === 1) throw badRequest('Cannot demote the last superadmin');
user.role = patch.role;
const user = findManagedMember(d, actor, groupId, email);
const self = user.email === actor.email;
const m = membership(user, groupId)!;
if (patch.role !== undefined && patch.role !== m.role) {
if (self) throw badRequest('You cannot change your own role');
m.role = patch.role;
}
if (patch.groupId !== undefined && patch.groupId !== user.groupId) {
if (!d.groups.some((g) => g.id === patch.groupId)) throw notFound('Group not found');
user.groupId = patch.groupId;
if (patch.superadmin !== undefined && patch.superadmin !== user.superadmin) {
if (self) throw badRequest('You cannot change your own superadmin status');
if (!patch.superadmin && countSuperadmins(d) === 1) throw badRequest('Cannot demote the last superadmin');
user.superadmin = patch.superadmin;
}
return user;
});
}
async deleteUser(actor: StoredUser, currentGroupId: string, email: string): Promise<void> {
await this.file.update((d) => {
const user = findManagedUser(d, actor, currentGroupId, email);
if (user.email === actor.email) throw badRequest('You cannot delete yourself');
if (user.role === 'superadmin' && countSuperadmins(d) === 1) throw badRequest('Cannot delete the last superadmin');
/**
* Removes the user from the group. For a user whose only group it is, this deletes the user with
* all keys (users always belong to at least one group). Returns whether the user was deleted.
*/
async removeMember(actor: StoredUser, groupId: string, email: string): Promise<{ deleted: boolean }> {
return this.file.update((d) => {
const user = findManagedMember(d, actor, groupId, email);
if (user.email === actor.email) throw badRequest('You cannot remove yourself');
if (user.memberships.length > 1) {
user.memberships = user.memberships.filter((m) => m.groupId !== groupId);
return { deleted: false };
}
if (user.superadmin && countSuperadmins(d) === 1) throw badRequest('Cannot delete the last superadmin');
d.users = d.users.filter((u) => u !== user);
return { deleted: true };
});
}
// ---- keys ----
// ---- keys (a key signs the user in to all their groups) ----
async createKey(
actor: StoredUser,
currentGroupId: string,
email: string,
label: string | undefined,
apiUrl: string,
): Promise<AccessKeyView> {
async createKey(actor: StoredUser, groupId: string, email: string, label: string | undefined, apiUrl: string): Promise<AccessKeyView> {
const plain = generateAccessKey();
const stored: StoredKey = {
id: randomUUID(),
@@ -227,7 +279,7 @@ export class UsersService {
encrypted: encryptSecret(plain, this.secret),
};
await this.file.update((d) => {
findManagedUser(d, actor, currentGroupId, email).keys.push(stored);
findManagedMember(d, actor, groupId, email).keys.push(stored);
});
return {
id: stored.id,
@@ -238,9 +290,9 @@ export class UsersService {
};
}
async deleteKey(actor: StoredUser, currentGroupId: string, email: string, keyId: string): Promise<void> {
async deleteKey(actor: StoredUser, groupId: string, email: string, keyId: string): Promise<void> {
await this.file.update((d) => {
const user = findManagedUser(d, actor, currentGroupId, email);
const user = findManagedMember(d, actor, groupId, email);
const before = user.keys.length;
user.keys = user.keys.filter((k) => k.id !== keyId);
if (user.keys.length === before) throw notFound('Key not found');
@@ -253,11 +305,22 @@ function sameName(a: string, b: string): boolean {
}
function countSuperadmins(d: UsersFile): number {
return d.users.filter((u) => u.role === 'superadmin').length;
return d.users.filter((u) => u.superadmin).length;
}
export function membership(user: StoredUser, groupId: string) {
return user.memberships.find((m) => m.groupId === groupId);
}
/** Effective role in a group; undefined when the user is not a member (and not a superadmin). */
export function roleIn(user: StoredUser, groupId: string): Role | undefined {
if (user.superadmin) return 'superadmin';
return membership(user, groupId)?.role;
}
export function isGroupAdmin(actor: StoredUser, groupId: string): boolean {
return actor.role === 'superadmin' || (actor.role === 'admin' && actor.groupId === groupId);
const role = roleIn(actor, groupId);
return role === 'superadmin' || role === 'admin';
}
export function requireGroupAdmin(actor: StoredUser, groupId: string): void {
@@ -265,15 +328,15 @@ export function requireGroupAdmin(actor: StoredUser, groupId: string): void {
}
export function requireSuperadmin(actor: StoredUser): void {
if (actor.role !== 'superadmin') throw forbidden();
if (!actor.superadmin) throw forbidden();
}
/** A user of the current group that the actor may manage (only superadmins manage superadmins). */
function findManagedUser(d: UsersFile, actor: StoredUser, currentGroupId: string, email: string): StoredUser {
requireGroupAdmin(actor, currentGroupId);
/** A member of the group that the actor may manage (only superadmins manage superadmins). */
function findManagedMember(d: UsersFile, actor: StoredUser, groupId: string, email: string): StoredUser {
requireGroupAdmin(actor, groupId);
const normalized = email.trim().toLowerCase();
const user = d.users.find((u) => u.email === normalized && u.groupId === currentGroupId);
if (!user) throw notFound('User not found');
if (user.role === 'superadmin') requireSuperadmin(actor);
const user = d.users.find((u) => u.email === normalized && membership(u, groupId));
if (!user) throw notFound('User not found in this group');
if (user.superadmin) requireSuperadmin(actor);
return user;
}
+88 -7
View File
@@ -24,7 +24,7 @@ async function setup() {
const { app, config } = await testApp();
const root = admin(app, SUPERADMIN);
const me = (await root('GET', '/me')).body as MeResponse;
return { app, config, root, defaultGroup: me.group };
return { app, config, root, defaultGroup: me.currentGroup };
}
describe('admin API authentication', () => {
@@ -32,7 +32,13 @@ describe('admin API authentication', () => {
const { root } = await setup();
const { status, body } = await root('GET', '/me');
expect(status).toBe(200);
expect(body).toMatchObject({ email: SUPERADMIN, role: 'superadmin', group: { name: 'default' } });
expect(body).toMatchObject({
email: SUPERADMIN,
superadmin: true,
role: 'superadmin',
groups: [{ name: 'default', role: 'admin' }],
currentGroup: { name: 'default' },
});
});
it('returns 401 without header or for unknown users', async () => {
@@ -77,18 +83,33 @@ describe('groups and current group', () => {
expect(((await stale('GET', '/me')).body as MeResponse).currentGroup.id).toBe(defaultGroup.id);
});
it('ignores the group cookie for non-superadmins', async () => {
it('lets members switch only between their own groups', async () => {
const { app, root, defaultGroup } = await setup();
const other = (await root('POST', '/groups', { name: 'Other' })).body as Group;
await root('POST', '/users', { email: 'adm@example.com', role: 'admin' });
const adm = admin(app, 'adm@example.com', `cardy_group=${other.id}`);
// Not a member of "Other" yet: the cookie is ignored and switching is refused.
expect(((await adm('GET', '/me')).body as MeResponse).currentGroup.id).toBe(defaultGroup.id);
expect((await adm('PUT', '/current-group', { groupId: other.id })).status).toBe(403);
expect((await adm('GET', '/groups')).status).toBe(403);
const inOther = admin(app, SUPERADMIN, `cardy_group=${other.id}`);
expect((await inOther('POST', '/users', { email: 'adm@example.com' })).status).toBe(201);
expect((await adm('PUT', '/current-group', { groupId: other.id })).status).toBe(200);
const me = (await adm('GET', '/me')).body as MeResponse;
expect(me.currentGroup.id).toBe(other.id);
expect(me.role).toBe('user');
expect(me.groups.map((g) => [g.name, g.role])).toEqual([
['default', 'admin'],
['Other', 'user'],
]);
// Admin in "default" only: no admin rights in "Other".
expect((await adm('GET', '/users')).status).toBe(403);
});
it('refuses to delete a group with a superadmin, deletes others with users and cards', async () => {
it('refuses to delete a group that is the only group of some user', async () => {
const { app, config, root, defaultGroup } = await setup();
// "default" is the superadmin's only group.
expect((await root('DELETE', `/groups/${defaultGroup.id}`)).status).toBe(409);
const other = (await root('POST', '/groups', { name: 'Other' })).body as Group;
@@ -97,9 +118,16 @@ describe('groups and current group', () => {
await inOther('POST', '/cards', { name: 'Shop', color: '#112233', code: '123', codeType: 'CODE128' });
expect(existsSync(path.join(config.dataDir, 'groups', other.id, 'cards.yaml'))).toBe(true);
const refused = await root('DELETE', `/groups/${other.id}`);
expect(refused.status).toBe(409);
expect((refused.body as { error: string }).error).toContain('u@example.com');
// Once the user has another group, the group can go: its cards are removed, the user stays.
await root('POST', '/users', { email: 'u@example.com' });
expect((await root('DELETE', `/groups/${other.id}`)).status).toBe(204);
expect(existsSync(path.join(config.dataDir, 'groups', other.id))).toBe(false);
expect((await admin(app, 'u@example.com')('GET', '/me')).status).toBe(401);
const me = (await admin(app, 'u@example.com')('GET', '/me')).body as MeResponse;
expect(me.groups.map((g) => g.id)).toEqual([defaultGroup.id]);
});
});
@@ -113,7 +141,8 @@ describe('users and keys', () => {
expect((await user('GET', '/users')).status).toBe(403);
expect((await user('GET', '/cards')).status).toBe(403);
expect((await adm('POST', '/users', { email: 'x@example.com', role: 'superadmin' })).status).toBe(403);
expect((await adm('POST', '/users', { email: 'x@example.com', role: 'superadmin' })).status).toBe(400);
expect((await adm('PATCH', '/users/user@example.com', { superadmin: true })).status).toBe(403);
expect((await adm('PATCH', `/users/${SUPERADMIN}`, { role: 'user' })).status).toBe(403);
expect((await adm('DELETE', `/users/${SUPERADMIN}`)).status).toBe(403);
expect((await adm('PATCH', '/users/adm@example.com', { role: 'user' })).status).toBe(400);
@@ -121,7 +150,59 @@ describe('users and keys', () => {
expect((await adm('PATCH', '/users/user@example.com', { role: 'admin' })).status).toBe(200);
expect((await adm('POST', '/users', { email: 'user@example.com' })).status).toBe(409);
expect((await adm('POST', '/users', { email: 'not-an-email' })).status).toBe(400);
expect((await root('PATCH', `/users/${SUPERADMIN}`, { role: 'admin' })).status).toBe(400);
expect((await root('PATCH', `/users/${SUPERADMIN}`, { superadmin: false })).status).toBe(400);
const promoted = await root('PATCH', '/users/user@example.com', { superadmin: true });
expect((promoted.body as UserView).superadmin).toBe(true);
// Now a superadmin: plain admins can no longer manage them.
expect((await adm('PATCH', '/users/user@example.com', { role: 'user' })).status).toBe(403);
});
it('keeps roles per group and removes users from one group or entirely', async () => {
const { app, root } = await setup();
const other = (await root('POST', '/groups', { name: 'Other' })).body as Group;
const inOther = admin(app, SUPERADMIN, `cardy_group=${other.id}`);
await root('POST', '/users', { email: 'multi@example.com', role: 'admin' });
const added = await inOther('POST', '/users', { email: 'multi@example.com', role: 'user' });
expect(added.status).toBe(201);
expect((added.body as UserView).role).toBe('user');
expect((added.body as UserView).groups.map((g) => [g.name, g.role])).toEqual([
['default', 'admin'],
['Other', 'user'],
]);
// Leaving one group keeps the user.
expect((await inOther('DELETE', '/users/multi@example.com')).body).toEqual({ deleted: false });
const me = (await admin(app, 'multi@example.com')('GET', '/me')).body as MeResponse;
expect(me.groups.map((g) => g.name)).toEqual(['default']);
// Removing the user from their only group deletes the user.
expect((await root('DELETE', '/users/multi@example.com')).body).toEqual({ deleted: true });
expect((await admin(app, 'multi@example.com')('GET', '/me')).status).toBe(401);
});
it('reads users.yaml from before multi-group', async () => {
const { mkdir, writeFile } = await import('node:fs/promises');
const { testConfig } = await import('./helpers.js');
const { buildApp } = await import('../src/app.js');
const config = await testConfig();
await mkdir(config.dataDir, { recursive: true });
await writeFile(
path.join(config.dataDir, 'users.yaml'),
[
'groups:',
' - { id: g1, name: Home }',
'users:',
` - { email: ${SUPERADMIN}, groupId: g1, role: superadmin, keys: [] }`,
' - { email: adm@example.com, groupId: g1, role: admin, keys: [] }',
' - { email: kid@example.com, groupId: g1, role: user, keys: [] }',
].join('\n'),
);
const app = await buildApp(config);
const view = (email: string) => admin(app, email)('GET', '/me').then((r) => r.body as MeResponse);
expect(await view(SUPERADMIN)).toMatchObject({ superadmin: true, role: 'superadmin', groups: [{ id: 'g1', role: 'admin' }] });
expect(await view('adm@example.com')).toMatchObject({ superadmin: false, role: 'admin', groups: [{ id: 'g1', role: 'admin' }] });
expect(await view('kid@example.com')).toMatchObject({ superadmin: false, role: 'user', groups: [{ id: 'g1', role: 'user' }] });
});
it('admin generates keys; users see their own keys with deep links', async () => {
+37 -1
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify';
import { describe, expect, it } from 'vitest';
import type { AccessKeyView, Card, Group } from '@cardy/shared';
import type { AccessKeyView, ApiAuthResponse, Card, Group } from '@cardy/shared';
import { SUPERADMIN, multipart, testApp } from './helpers.js';
const adminHeaders = { 'remote-email': SUPERADMIN, 'x-cardy-admin': '1' };
@@ -179,6 +179,42 @@ describe('client API', () => {
expect((patched.json() as Card).tags).toEqual(['a', 'b', 'c']);
});
it('serves each of the owner\'s groups and keeps /cards on the first one', async () => {
const { app, key } = await setup();
const api = client(app, key);
const other = (
await app.inject({ method: 'POST', url: '/admin/groups', headers: adminHeaders, payload: { name: 'Other' } })
).json() as Group;
const stranger = (
await app.inject({ method: 'POST', url: '/admin/groups', headers: adminHeaders, payload: { name: 'Stranger' } })
).json() as Group;
await app.inject({
method: 'POST',
url: '/admin/users',
headers: { ...adminHeaders, cookie: `cardy_group=${other.id}` },
payload: { email: SUPERADMIN },
});
const auth = (await api('GET', '/auth')).json() as ApiAuthResponse;
expect(auth.groups.map((g) => g.name)).toEqual(['default', 'Other']);
expect(auth.group.name).toBe('default');
const inOther = (await api('POST', `/groups/${other.id}/cards`, cardParts({ name: 'In other' }))).json() as Card;
const inDefault = (await api('POST', `/groups/${auth.groups[0]!.id}/cards`, cardParts({ name: 'In default' }))).json() as Card;
expect(((await api('GET', `/groups/${other.id}/cards`)).json() as Card[]).map((c) => c.name)).toEqual(['In other']);
// The group-less routes (apps from before multi-group) use the first group.
expect(((await api('GET', '/cards')).json() as Card[]).map((c) => c.name)).toEqual(['In default']);
// Cards are addressed within their group only.
expect((await api('DELETE', `/groups/${other.id}/cards/${inDefault.id}`)).statusCode).toBe(404);
expect((await api('PUT', `/groups/${other.id}/cards/${inOther.id}`, [{ name: 'name', value: 'Renamed' }])).json()).toMatchObject({
name: 'Renamed',
});
// Groups the owner is not a member of do not exist for the key.
expect((await api('GET', `/groups/${stranger.id}/cards`)).statusCode).toBe(404);
expect((await api('POST', `/groups/${stranger.id}/cards`, cardParts())).statusCode).toBe(404);
});
it('returns JSON 404 for unknown API routes', async () => {
const { app, key } = await setup();
const res = await client(app, key)('GET', '/nope');