From 69c7ae30d2d5377073cebc000ea2e28d16fadf9b Mon Sep 17 00:00:00 2001
From: joshunrau
Date: Tue, 3 Nov 2026 20:54:26 -0500
Subject: [PATCH 1/5] feat(api): add permission sets
A permission set is a named list of rules an administrator defines once and links users to,
instead of rebuilding the same additional permissions for each user. Each rule applies to every
group or to the holder's groups, resolved at sign-in like a group manager's own rules. A set is
linked, not copied: editing it changes every holder from their next sign-in, and deleting it
removes it from its holders.
A set and custom additional permissions are mutually exclusive. PUT /v1/users/:id/permissions
now takes { kind: 'permissionSet', permissionSetId } or { kind: 'custom', permissions }, and each
write empties the other. A set may not grant manage all, an administrator never holds one, and
every /v1/permission-sets route is admin-only, since editing a set is a grant to each holder.
Co-Authored-By: Claude Opus 5.5
---
.../docs/architecture/auth-and-permissions.md | 36 ++-
apps/api/AGENTS.md | 21 +-
apps/api/prisma/schema.prisma | 31 ++
.../__tests__/assignments.service.test.ts | 15 +-
.../auth/__tests__/ability.factory.test.ts | 247 ++++++++++-----
.../src/auth/__tests__/auth.service.test.ts | 49 ++-
apps/api/src/auth/ability.factory.ts | 43 ++-
apps/api/src/auth/auth.service.ts | 7 +-
apps/api/src/core/prisma.ts | 1 +
.../__tests__/groups.controller.test.ts | 27 +-
.../instrument-records.service.test.ts | 3 +-
.../__tests__/instruments.controller.test.ts | 23 +-
.../__tests__/instruments.service.test.ts | 13 +-
apps/api/src/main.ts | 2 +
.../permission-sets.controller.test.ts | 108 +++++++
.../__tests__/permission-sets.service.test.ts | 134 +++++++++
.../permission-sets.controller.ts | 56 ++++
.../permission-sets/permission-sets.module.ts | 11 +
.../permission-sets.service.ts | 75 +++++
.../__tests__/subjects.service.test.ts | 23 +-
.../users/__tests__/users.controller.test.ts | 44 ++-
.../src/users/__tests__/users.service.test.ts | 119 ++++++--
apps/api/src/users/users.controller.ts | 4 +-
apps/api/src/users/users.module.ts | 3 +-
apps/api/src/users/users.service.ts | 54 +++-
.../test/suites/02-user-permissions.suite.ts | 4 +-
.../suites/03-record-file-deletion.suite.ts | 1 +
.../test/suites/04-permission-sets.suite.ts | 282 ++++++++++++++++++
packages/schemas/AGENTS.md | 8 +-
packages/schemas/package.json | 1 +
.../__tests__/permission-set.test.ts | 125 ++++++++
.../src/permission-set/permission-set.ts | 107 +++++++
.../schemas/src/setup/__tests__/setup.test.ts | 13 +-
packages/schemas/src/setup/setup.ts | 4 +-
.../schemas/src/user/__tests__/user.test.ts | 46 ++-
packages/schemas/src/user/user.ts | 74 +++--
36 files changed, 1589 insertions(+), 225 deletions(-)
create mode 100644 apps/api/src/permission-sets/__tests__/permission-sets.controller.test.ts
create mode 100644 apps/api/src/permission-sets/__tests__/permission-sets.service.test.ts
create mode 100644 apps/api/src/permission-sets/permission-sets.controller.ts
create mode 100644 apps/api/src/permission-sets/permission-sets.module.ts
create mode 100644 apps/api/src/permission-sets/permission-sets.service.ts
create mode 100644 apps/api/test/suites/04-permission-sets.suite.ts
create mode 100644 packages/schemas/src/permission-set/__tests__/permission-set.test.ts
create mode 100644 packages/schemas/src/permission-set/permission-set.ts
diff --git a/.agents/docs/architecture/auth-and-permissions.md b/.agents/docs/architecture/auth-and-permissions.md
index 9400ca59d..1e85701a1 100644
--- a/.agents/docs/architecture/auth-and-permissions.md
+++ b/.agents/docs/architecture/auth-and-permissions.md
@@ -42,6 +42,7 @@ The full inventory of non-ordinary access declarations, current as of writing:
| `PATCH /v1/users/self-update/:id` | `{ action: 'read', subject: 'User' }` | Deliberately weak; `UsersService.updateSelfById` throws `ForbiddenException` unless `id === currentUser.id`. The controller carries a comment saying so. |
| `PUT /v1/users/:id/permissions` | `ADMIN_ONLY` | `ADMIN` alone. An `update User` grant is one of the things this route hands out, so it must not be enough to reach it, or the holder could grant themselves `manage all`. `$UpdateUserData` no longer carries the field either. |
| `POST /v1/users`, `PATCH /v1/users/:id`, `PATCH /v1/users/:id/archive`, `PATCH /v1/users/:id/unarchive` | `ADMIN_ONLY` | `ADMIN` alone, whatever `User` action a grant names. Level, groups and password are what the rest of a user's access derives from, so a grantee could otherwise promote themselves, join every group, or log in as an admin whose password they set. `UsersService` also refuses an admin archiving, unarchiving, disabling or demoting their own account, so the last one cannot lock every admin-only route. |
+| `GET`, `POST /v1/permission-sets`, `GET`, `PATCH`, `DELETE /v1/permission-sets/:id` | `ADMIN_ONLY` | `ADMIN` alone, reads included. A set's rules are applied to every holder at sign-in, so editing one is a grant to each of them, and only an administrator may grant; holders and group managers have no use for reading it. `PermissionSet` is deliberately absent from `$AppSubjectName`, so no grant can name it either (see below). |
| `GET /v1/summary` | five-element array (`read` on `Instrument`, `InstrumentRecord`, `Session`, `Subject`, `User`) | The only use of the multi-element array form; all five must pass. |
Adding a fourth `'public'` route, or a second `[]`, is a security decision — raise it rather than
@@ -113,11 +114,15 @@ Prisma results already carry `__modelName` — it is a computed field added by
## Where an ability comes from
1. `AuthService.login` builds one with `AbilityFactory.createForPayload`, switching on
- `basePermissionLevel` (`ADMIN` / `GROUP_MANAGER` / `STANDARD`) and then applying the user's
- `additionalPermissions` on top. A stored rule carrying a `groupId` is applied with that model's
- group condition from `GROUP_SCOPED_CONDITIONS` (`src/auth/ability.factory.ts`), so it reaches
- only that group's rows; a `null` `groupId` is an unconditional rule, which is what every rule
- written before the field existed reads back as.
+ `basePermissionLevel` (`ADMIN` / `GROUP_MANAGER` / `STANDARD`) and then applying, on top, the
+ rules of the user's permission set and the user's `additionalPermissions`. A stored rule
+ carrying a `groupId` is applied with that model's group condition from `GROUP_SCOPED_CONDITIONS`
+ (`src/auth/ability.factory.ts`), so it reaches only that group's rows; a `null` `groupId` is an
+ unconditional rule, which is what every rule written before the field existed reads back as.
+ A permission set rule names no group: `HOLDER_GROUPS` gets the same condition over all of the
+ holder's groups, the way a group manager's own rules do, and `ALL_GROUPS` is unconditional.
+ `login` reads the set through `UsersService.findByUsername` and passes its rules as
+ `createForPayload`'s second, required, parameter; the set itself is not put on the payload.
2. The **serialized rules** (`ability.rules`) are signed into the JWT alongside the payload,
`expiresIn: '1h'`.
3. On every request `JwtStrategy.validate` rebuilds the ability from those rules with
@@ -125,8 +130,9 @@ Prisma results already carry `__modelName` — it is a computed field added by
Consequences worth knowing:
-- **Permissions are frozen into the token.** Changing a user's groups, base level or additional
- permissions has no effect until they obtain a new token. There is no refresh endpoint.
+- **Permissions are frozen into the token.** Changing a user's groups, base level, additional
+ permissions or permission set — or editing the set itself — has no effect until they obtain a new
+ token. There is no refresh endpoint.
- **`basePermissionLevel` is nullable** and the `switch` has no `default`, so a user with `null` gets
only whatever `additionalPermissions` grants — usually nothing.
- **JWT expiry is ignored in development.** `ignoreExpiration: configService.getOrThrow('NODE_ENV') === 'development'`
@@ -167,11 +173,13 @@ hand-written:
**These two currently agree with each other but not with the derived list.** They contain `all`,
`Assignment`, `Group`, `Instrument`, `InstrumentRecord`, `InstrumentRepo`, `Session`, `Subject`,
-`User`. The Prisma schema also defines `AuditLog`, `InstrumentRecordFile`, `InstrumentRepoSyncLog`
-and `SetupState`, which
-are therefore valid in `@RouteAccess` and in `AbilityFactory` rules (`InstrumentRecordFile` is used
-in both) but cannot be granted as an `additionalPermission`. Treat that as the existing state, not
-as a licence to widen it silently.
+`User`. The Prisma schema also defines `AuditLog`, `InstrumentRecordFile`, `InstrumentRepoSyncLog`,
+`PermissionSet` and `SetupState`, which are therefore valid in `@RouteAccess` and in
+`AbilityFactory` rules (`InstrumentRecordFile` is used in both) but cannot be granted as an
+`additionalPermission` or as a permission set rule, whose `subject` uses the same enum. Treat that
+as the existing state, not as a licence to widen it silently. `PermissionSet` in particular must
+stay out: a grant on it would let its holder edit a set they hold, and so grant themselves anything
+a set may contain.
A third list, `$GroupScopableSubjectName` in the same schemas file, says which of these a per-user
permission may be confined to one group on. It is derived from `$AppSubjectName` by excluding `all`
@@ -185,7 +193,9 @@ any other subject at the boundary, and the factory throws on one it meets anyway
**A pair can be storable and still do nothing.** Every route that writes a user is `ADMIN_ONLY`, so
a grant of any `User` action but `read` reaches nothing. `isGrantablePermission`
(`packages/schemas/src/core/core.ts`) is that rule: `$UpdateUserPermissionsData` refuses what it
-refuses, and the web permissions editor offers only what it allows. `$UserPermission` itself does
+refuses, and the web permissions editor offers only what it allows. A permission set applies
+`isPermissionSetGrantable` (`packages/schemas/src/permission-set/permission-set.ts`) instead, which
+also refuses `manage all`: one edit to the set would otherwise make every holder an administrator. `$UserPermission` itself does
not apply it, because it is also the read model and grants stored before the rule still have to
parse; the editor marks those "No effect" and leaves them out of its next save. A new admin-only
route that a grantable pair used to reach belongs in the same predicate.
diff --git a/apps/api/AGENTS.md b/apps/api/AGENTS.md
index d60832522..c9c9c2dcd 100644
--- a/apps/api/AGENTS.md
+++ b/apps/api/AGENTS.md
@@ -121,10 +121,11 @@ CASL subjects are derived automatically from the Prisma `TypeMap`, so every mode
without you doing anything. But the subjects a user can be _granted_ through `additionalPermissions`
come from **two hand-maintained lists that must agree with each other**: `enum AppSubject` in
`prisma/schema.prisma` and `$AppSubjectName` in `packages/schemas/src/core/core.ts`. Both currently
-list the same eight models plus `all`. Four models — `AuditLog`, `InstrumentRecordFile`,
-`InstrumentRepoSyncLog` and `SetupState` — appear in neither, so no user can be granted a permission naming them; they are
-reachable only through rules the ability factory writes itself. Adding a model that users should be
-able to hold a permission on means editing both lists.
+list the same eight models plus `all`. Five models — `AuditLog`, `InstrumentRecordFile`,
+`InstrumentRepoSyncLog`, `PermissionSet` and `SetupState` — appear in neither, so no user can be
+granted a permission naming them; they are reachable only through rules the ability factory writes
+itself. `PermissionSet` must stay that way: a grant on it would let its holder edit a set they hold.
+Adding a model that users should be able to hold a permission on means editing both lists.
A granted permission may be confined to one group. `AuthRule.groupId` names it (`null`, which is
what every rule written before the field existed reads back as, means every group), and
@@ -137,6 +138,18 @@ on a `User` action; the route inventory in the architecture doc below says why.
`UsersService.updatePermissions` accepts a scoped grant only for a group the user belongs to, and
`updateById` drops the grants scoped to a group the user is removed from.
+A user may instead hold a **permission set** (`src/permission-sets/`): an admin-defined, named list
+of rules, linked through `User.permissionSetId`, whose rules `createForPayload` applies alongside the
+base level. It takes them as a **required** second parameter, so the one caller, `AuthService.login`,
+cannot drop them; login reads the set through `UsersService.findByUsername`'s include and never puts
+it on the token payload. A rule's scope is relative — `HOLDER_GROUPS` is the holder's groups at
+sign-in, through the same `GROUP_SCOPED_CONDITIONS` — so a set names no group. A set and custom
+`additionalPermissions` are mutually exclusive: `PUT /v1/users/:id/permissions` takes
+`{ kind: 'permissionSet' }` or `{ kind: 'custom' }` and each write empties the other. An `ADMIN` never
+holds a set — `POST /v1/users` and the permissions route refuse one, and `updateById` disconnects it
+on promotion — and deleting a set clears every holder through `onDelete: SetNull`. Every
+`/v1/permission-sets` route is `ADMIN_ONLY`, because editing a set is a grant to each holder.
+
Background: `.agents/docs/architecture/auth-and-permissions.md`.
## Validation
diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma
index c23295192..082856166 100644
--- a/apps/api/prisma/schema.prisma
+++ b/apps/api/prisma/schema.prisma
@@ -349,6 +349,32 @@ type AuthRule {
groupId String? @db.ObjectId
}
+enum PermissionSetScope {
+ ALL_GROUPS
+ HOLDER_GROUPS
+}
+
+/// A rule in a permission set. Its scope is relative to the holder rather than naming a group, so one
+/// set can serve users in different groups; it is resolved against the holder's groups at sign-in.
+type PermissionSetRule {
+ action AppAction
+ subject AppSubject
+ scope PermissionSetScope
+}
+
+/// A named list of rules an administrator defines once and links users to. Not in `enum AppSubject`
+/// on purpose: a grant on it would let its holder edit a set they hold, and so grant themselves more.
+model PermissionSet {
+ createdAt DateTime @default(now()) @db.Date
+ updatedAt DateTime @updatedAt @db.Date
+ id String @id @default(auto()) @map("_id") @db.ObjectId
+ name String @unique
+ rules PermissionSetRule[]
+ users User[]
+
+ @@map("PermissionSetModel")
+}
+
model User {
createdAt DateTime @default(now()) @db.Date
updatedAt DateTime @updatedAt @db.Date
@@ -373,6 +399,11 @@ model User {
email String?
disabled Boolean?
archivedAt DateTime? @db.Date
+ /// The permission set this user holds, if any. Mutually exclusive with a non-empty
+ /// `additionalPermissions`: `PUT /v1/users/:id/permissions` is the one writer of both, and always
+ /// writes them together. Optional, so users created before this field existed read back as none.
+ permissionSetId String? @db.ObjectId
+ permissionSet PermissionSet? @relation(fields: [permissionSetId], references: [id], onDelete: SetNull)
@@unique([username])
@@map("UserModel")
diff --git a/apps/api/src/assignments/__tests__/assignments.service.test.ts b/apps/api/src/assignments/__tests__/assignments.service.test.ts
index 5d37c3d9e..64c8153d3 100644
--- a/apps/api/src/assignments/__tests__/assignments.service.test.ts
+++ b/apps/api/src/assignments/__tests__/assignments.service.test.ts
@@ -39,12 +39,15 @@ const userAt = (
additionalPermissions: Permissions = []
) => {
return {
- ability: new AbilityFactory({ verbose: vi.fn() } as any).createForPayload({
- additionalPermissions,
- basePermissionLevel,
- groups: [{ id: groupId }],
- id: 'user-1'
- } as any),
+ ability: new AbilityFactory({ verbose: vi.fn() } as any).createForPayload(
+ {
+ additionalPermissions,
+ basePermissionLevel,
+ groups: [{ id: groupId }],
+ id: 'user-1'
+ } as any,
+ []
+ ),
id: 'user-1'
} as any;
};
diff --git a/apps/api/src/auth/__tests__/ability.factory.test.ts b/apps/api/src/auth/__tests__/ability.factory.test.ts
index 8edeb185b..751c88c12 100644
--- a/apps/api/src/auth/__tests__/ability.factory.test.ts
+++ b/apps/api/src/auth/__tests__/ability.factory.test.ts
@@ -43,22 +43,25 @@ describe('AbilityFactory', () => {
username: 'admin-user'
};
- const ability = abilityFactory.createForPayload(payload as any);
+ const ability = abilityFactory.createForPayload(payload as any, []);
expect(ability.can('manage', 'all')).toBe(true);
});
it('should reduce a user owing a password reset to reading themselves, whatever their permission level', () => {
- const ability = abilityFactory.createForPayload({
- additionalPermissions: [{ action: 'read', groupId: null, subject: 'Subject' }],
- basePermissionLevel: 'ADMIN',
- firstName: 'Test',
- groups: [{ id: 'group-1' }],
- id: 'user-1',
- lastName: 'User',
- mustResetPassword: true,
- username: 'admin-user'
- } as any);
+ const ability = abilityFactory.createForPayload(
+ {
+ additionalPermissions: [{ action: 'read', groupId: null, subject: 'Subject' }],
+ basePermissionLevel: 'ADMIN',
+ firstName: 'Test',
+ groups: [{ id: 'group-1' }],
+ id: 'user-1',
+ lastName: 'User',
+ mustResetPassword: true,
+ username: 'admin-user'
+ } as any,
+ []
+ );
// Allowed, because `updateSelfById` is gated on it and is the only way out of the reset.
expect(ability.can('read', subject('User', { id: 'user-1' }) as any)).toBe(true);
@@ -83,7 +86,7 @@ describe('AbilityFactory', () => {
username: 'admin-user'
};
- const ability = abilityFactory.createForPayload(payload as any);
+ const ability = abilityFactory.createForPayload(payload as any, []);
expect(ability.can('manage', subject('Group', { id: 'group-1' }) as any)).toBe(true);
expect(ability.can('manage', subject('Group', { id: 'group-2' }) as any)).toBe(false);
@@ -99,29 +102,35 @@ describe('AbilityFactory', () => {
['InstrumentRecord', [], { groupId: { in: [] } }],
['Subject', [], { groupIds: { hasSome: [] } }]
] as const)("should confine a group manager's %s query to the groups %j", (modelName, groupIds, condition) => {
- const ability = abilityFactory.createForPayload({
- additionalPermissions: undefined,
- basePermissionLevel: 'GROUP_MANAGER',
- firstName: 'Test',
- groups: groupIds.map((id) => ({ id })),
- id: 'user-1',
- lastName: 'User',
- username: 'manager-user'
- } as any);
+ const ability = abilityFactory.createForPayload(
+ {
+ additionalPermissions: undefined,
+ basePermissionLevel: 'GROUP_MANAGER',
+ firstName: 'Test',
+ groups: groupIds.map((id) => ({ id })),
+ id: 'user-1',
+ lastName: 'User',
+ username: 'manager-user'
+ } as any,
+ []
+ );
expect(accessibleQuery(ability, 'read', modelName)).toStrictEqual({ OR: [condition] });
});
it('should restrict series deletion to the manager group', () => {
- const ability = abilityFactory.createForPayload({
- additionalPermissions: undefined,
- basePermissionLevel: 'GROUP_MANAGER',
- firstName: 'Test',
- groups: [{ id: 'group-1' }],
- id: 'user-1',
- lastName: 'User',
- username: 'manager-user'
- } as any);
+ const ability = abilityFactory.createForPayload(
+ {
+ additionalPermissions: undefined,
+ basePermissionLevel: 'GROUP_MANAGER',
+ firstName: 'Test',
+ groups: [{ id: 'group-1' }],
+ id: 'user-1',
+ lastName: 'User',
+ username: 'manager-user'
+ } as any,
+ []
+ );
expect(ability.can('delete', subject('Instrument', { seriesGroupId: 'group-1' }) as any)).toBe(true);
expect(ability.can('delete', subject('Instrument', { seriesGroupId: 'group-2' }) as any)).toBe(false);
@@ -141,7 +150,7 @@ describe('AbilityFactory', () => {
username: 'standard-user'
};
- const ability = abilityFactory.createForPayload(payload as any);
+ const ability = abilityFactory.createForPayload(payload as any, []);
expect(ability.can('read', subject('User', { id: 'user-1' }) as any)).toBe(true);
expect(ability.can('read', subject('User', { id: 'user-2' }) as any)).toBe(false);
@@ -159,7 +168,7 @@ describe('AbilityFactory', () => {
username: 'standard-user'
};
- const ability = abilityFactory.createForPayload(payload as any);
+ const ability = abilityFactory.createForPayload(payload as any, []);
expect(ability.can('create', subject('InstrumentRecordFile', { groupId: 'group-1' }) as any)).toBe(true);
expect(ability.can('create', subject('InstrumentRecordFile', { groupId: 'group-2' }) as any)).toBe(false);
@@ -178,7 +187,7 @@ describe('AbilityFactory', () => {
username: 'manager-user'
};
- const ability = abilityFactory.createForPayload(payload as any);
+ const ability = abilityFactory.createForPayload(payload as any, []);
expect(ability.can('create', subject('InstrumentRecordFile', { groupId: 'group-1' }) as any)).toBe(true);
expect(ability.can('create', subject('InstrumentRecordFile', { groupId: 'group-2' }) as any)).toBe(false);
@@ -187,15 +196,18 @@ describe('AbilityFactory', () => {
it.each(Object.entries(GROUP_SCOPED_ROWS))(
'should confine a granted %s permission to the group it names',
(subjectName, { inGroup, outOfGroup }) => {
- const ability = abilityFactory.createForPayload({
- additionalPermissions: [{ action: 'read', groupId: 'group-1', subject: subjectName }],
- basePermissionLevel: null,
- firstName: 'Test',
- groups: [],
- id: 'user-1',
- lastName: 'User',
- username: 'granted-user'
- } as any);
+ const ability = abilityFactory.createForPayload(
+ {
+ additionalPermissions: [{ action: 'read', groupId: 'group-1', subject: subjectName }],
+ basePermissionLevel: null,
+ firstName: 'Test',
+ groups: [],
+ id: 'user-1',
+ lastName: 'User',
+ username: 'granted-user'
+ } as any,
+ []
+ );
expect(ability.can('read', subject(subjectName, inGroup) as any)).toBe(true);
expect(ability.can('read', subject(subjectName, outOfGroup) as any)).toBe(false);
@@ -203,15 +215,18 @@ describe('AbilityFactory', () => {
);
it('should apply an unscoped grant to every group, as every grant was before scoping existed', () => {
- const ability = abilityFactory.createForPayload({
- additionalPermissions: [{ action: 'read', groupId: null, subject: 'Subject' }],
- basePermissionLevel: null,
- firstName: 'Test',
- groups: [],
- id: 'user-1',
- lastName: 'User',
- username: 'granted-user'
- } as any);
+ const ability = abilityFactory.createForPayload(
+ {
+ additionalPermissions: [{ action: 'read', groupId: null, subject: 'Subject' }],
+ basePermissionLevel: null,
+ firstName: 'Test',
+ groups: [],
+ id: 'user-1',
+ lastName: 'User',
+ username: 'granted-user'
+ } as any,
+ []
+ );
expect(ability.can('read', subject('Subject', { groupIds: ['group-1'] }) as any)).toBe(true);
expect(ability.can('read', subject('Subject', { groupIds: ['group-2'] }) as any)).toBe(true);
@@ -219,15 +234,18 @@ describe('AbilityFactory', () => {
});
it('should add a scoped grant on top of the base level without widening anything else', () => {
- const ability = abilityFactory.createForPayload({
- additionalPermissions: [{ action: 'read', groupId: 'group-1', subject: 'InstrumentRecord' }],
- basePermissionLevel: 'STANDARD',
- firstName: 'Test',
- groups: [{ id: 'group-1' }],
- id: 'user-1',
- lastName: 'User',
- username: 'standard-user'
- } as any);
+ const ability = abilityFactory.createForPayload(
+ {
+ additionalPermissions: [{ action: 'read', groupId: 'group-1', subject: 'InstrumentRecord' }],
+ basePermissionLevel: 'STANDARD',
+ firstName: 'Test',
+ groups: [{ id: 'group-1' }],
+ id: 'user-1',
+ lastName: 'User',
+ username: 'standard-user'
+ } as any,
+ []
+ );
expect(ability.can('read', subject('InstrumentRecord', { groupId: 'group-1' }) as any)).toBe(true);
expect(ability.can('read', subject('Subject', { groupIds: ['group-1'] }) as any)).toBe(true);
@@ -237,15 +255,18 @@ describe('AbilityFactory', () => {
it('should refuse to build an ability from a stored grant confined to a group on a resource that cannot be', () => {
const build = () => {
- return abilityFactory.createForPayload({
- additionalPermissions: [{ action: 'read', groupId: 'group-1', subject: 'Instrument' }],
- basePermissionLevel: null,
- firstName: 'Test',
- groups: [],
- id: 'user-1',
- lastName: 'User',
- username: 'granted-user'
- } as any);
+ return abilityFactory.createForPayload(
+ {
+ additionalPermissions: [{ action: 'read', groupId: 'group-1', subject: 'Instrument' }],
+ basePermissionLevel: null,
+ firstName: 'Test',
+ groups: [],
+ id: 'user-1',
+ lastName: 'User',
+ username: 'granted-user'
+ } as any,
+ []
+ );
};
expect(build).toThrow("Cannot confine permission 'read Instrument' to group 'group-1'");
@@ -269,9 +290,95 @@ describe('AbilityFactory', () => {
username: 'some-user'
};
- const ability = abilityFactory.createForPayload(payload as any);
+ const ability = abilityFactory.createForPayload(payload as any, []);
expect(accessibleQuery(ability, 'read', 'Session')).toBeDefined();
}
);
+ describe('permission set rules', () => {
+ const holderPayload = (groupIds: string[], overrides: object = {}) => ({
+ additionalPermissions: [],
+ basePermissionLevel: 'STANDARD',
+ firstName: 'Test',
+ groups: groupIds.map((id) => ({ id })),
+ id: 'user-1',
+ lastName: 'User',
+ username: 'holder',
+ ...overrides
+ });
+
+ it.each(Object.entries(GROUP_SCOPED_ROWS))(
+ "should confine a %s rule scoped to the holder's groups to those groups",
+ (subjectName, { inGroup, outOfGroup }) => {
+ const ability = abilityFactory.createForPayload(holderPayload(['group-1']) as any, [
+ { action: 'read', scope: 'HOLDER_GROUPS', subject: subjectName as GroupScopableSubjectName }
+ ]);
+
+ expect(ability.can('read', subject(subjectName, inGroup) as any)).toBe(true);
+ expect(ability.can('read', subject(subjectName, outOfGroup) as any)).toBe(false);
+ }
+ );
+
+ it("should reach every one of the holder's groups, not only the first", () => {
+ const ability = abilityFactory.createForPayload(holderPayload(['group-1', 'group-2']) as any, [
+ { action: 'read', scope: 'HOLDER_GROUPS', subject: 'InstrumentRecord' }
+ ]);
+
+ expect(accessibleQuery(ability, 'read', 'InstrumentRecord')).toStrictEqual({
+ OR: [{ groupId: { in: ['group-1', 'group-2'] } }]
+ });
+ });
+
+ it('should apply a rule scoped to every group to rows in groups the holder is not in', () => {
+ const ability = abilityFactory.createForPayload(holderPayload(['group-1']) as any, [
+ { action: 'read', scope: 'ALL_GROUPS', subject: 'InstrumentRecord' }
+ ]);
+
+ expect(ability.can('read', subject('InstrumentRecord', { groupId: 'group-1' }) as any)).toBe(true);
+ expect(ability.can('read', subject('InstrumentRecord', { groupId: 'group-2' }) as any)).toBe(true);
+ expect(ability.can('update', subject('InstrumentRecord', { groupId: 'group-1' }) as any)).toBe(false);
+ });
+
+ it("should match nothing for a holder in no group when a rule is scoped to the holder's groups", () => {
+ const ability = abilityFactory.createForPayload(holderPayload([], { basePermissionLevel: null }) as any, [
+ { action: 'read', scope: 'HOLDER_GROUPS', subject: 'InstrumentRecord' }
+ ]);
+
+ expect(ability.can('read', subject('InstrumentRecord', { groupId: 'group-1' }) as any)).toBe(false);
+ expect(accessibleQuery(ability, 'read', 'InstrumentRecord')).toStrictEqual({ OR: [{ groupId: { in: [] } }] });
+ });
+
+ it("should refuse to build an ability from a stored rule scoped to the holder's groups on a resource no group owns", () => {
+ const build = () => {
+ return abilityFactory.createForPayload(holderPayload(['group-1']) as any, [
+ { action: 'read', scope: 'HOLDER_GROUPS', subject: 'Instrument' }
+ ]);
+ };
+
+ expect(build).toThrow("Cannot confine permission set rule 'read Instrument' to the holder's groups");
+ });
+
+ it('should apply the set alongside custom grants and the base level, widening nothing else', () => {
+ const ability = abilityFactory.createForPayload(
+ holderPayload(['group-1'], {
+ additionalPermissions: [{ action: 'read', groupId: 'group-1', subject: 'Assignment' }]
+ }) as any,
+ [{ action: 'read', scope: 'HOLDER_GROUPS', subject: 'InstrumentRecord' }]
+ );
+
+ expect(ability.can('read', subject('InstrumentRecord', { groupId: 'group-1' }) as any)).toBe(true);
+ expect(ability.can('read', subject('Assignment', { groupId: 'group-1' }) as any)).toBe(true);
+ expect(ability.can('read', subject('Subject', { groupIds: ['group-1'] }) as any)).toBe(true);
+ expect(ability.can('manage', 'all')).toBe(false);
+ });
+
+ it('should ignore the set for a holder owing a password reset, who may only read themselves', () => {
+ const ability = abilityFactory.createForPayload(holderPayload(['group-1'], { mustResetPassword: true }) as any, [
+ { action: 'read', scope: 'ALL_GROUPS', subject: 'Subject' }
+ ]);
+
+ expect(ability.can('read', 'Subject')).toBe(false);
+ expect(ability.can('read', subject('User', { id: 'user-1' }) as any)).toBe(true);
+ });
+ });
});
diff --git a/apps/api/src/auth/__tests__/auth.service.test.ts b/apps/api/src/auth/__tests__/auth.service.test.ts
index 74c58ab86..7198eaa71 100644
--- a/apps/api/src/auth/__tests__/auth.service.test.ts
+++ b/apps/api/src/auth/__tests__/auth.service.test.ts
@@ -37,7 +37,7 @@ describe('AuthService', () => {
let usersService: MockedInstance;
const requestUserFor = (basePermissionLevel: 'ADMIN' | 'GROUP_MANAGER' | 'STANDARD'): RequestUser => {
- const ability = abilityFactory.createForPayload({ ...BASE_PAYLOAD, basePermissionLevel } as any);
+ const ability = abilityFactory.createForPayload({ ...BASE_PAYLOAD, basePermissionLevel } as any, []);
return { ...BASE_PAYLOAD, ability, basePermissionLevel } as unknown as RequestUser;
};
@@ -82,6 +82,8 @@ describe('AuthService', () => {
id: 'user-1',
lastName: 'User',
mustResetPassword: null,
+ permissionSet: null,
+ permissionSetId: null,
phoneNumber: null,
sex: null,
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
@@ -139,6 +141,51 @@ describe('AuthService', () => {
});
});
+ it("should sign the rules of the user's permission set, confined to the user's groups where the set says so", async () => {
+ usersService.findByUsername.mockResolvedValue(
+ storedUser({
+ groups: [{ id: 'group-1' } as StoredUser['groups'][number]],
+ permissionSet: {
+ __modelName: 'PermissionSet',
+ createdAt: new Date('2026-01-01T00:00:00.000Z'),
+ id: 'set-1',
+ name: 'Research Assistant',
+ rules: [{ action: 'read', scope: 'HOLDER_GROUPS', subject: 'InstrumentRecord' }],
+ updatedAt: new Date('2026-01-01T00:00:00.000Z')
+ },
+ permissionSetId: 'set-1'
+ })
+ );
+ cryptoService.comparePassword.mockResolvedValue(true);
+ await authService.login(credentials);
+ expect(jwtService.signAsync.mock.lastCall?.[0]).toMatchObject({
+ permissions: expect.arrayContaining([
+ { action: 'read', conditions: { groupId: { in: ['group-1'] } }, subject: 'InstrumentRecord' }
+ ])
+ });
+ });
+
+ it('should leave the permission set itself off the signed payload, so it does not grow the token', async () => {
+ usersService.findByUsername.mockResolvedValue(
+ storedUser({
+ permissionSet: {
+ __modelName: 'PermissionSet',
+ createdAt: new Date('2026-01-01T00:00:00.000Z'),
+ id: 'set-1',
+ name: 'Research Assistant',
+ rules: [{ action: 'read', scope: 'ALL_GROUPS', subject: 'Subject' }],
+ updatedAt: new Date('2026-01-01T00:00:00.000Z')
+ },
+ permissionSetId: 'set-1'
+ })
+ );
+ cryptoService.comparePassword.mockResolvedValue(true);
+ await authService.login(credentials);
+ const signed = jwtService.signAsync.mock.lastCall?.[0] as object;
+ expect(signed).not.toHaveProperty('permissionSet');
+ expect(signed).not.toHaveProperty('permissionSetId');
+ });
+
it('should treat an account with no reset flag as not needing a reset, since older accounts never stored one', async () => {
usersService.findByUsername.mockResolvedValue(storedUser());
cryptoService.comparePassword.mockResolvedValue(true);
diff --git a/apps/api/src/auth/ability.factory.ts b/apps/api/src/auth/ability.factory.ts
index ce7111664..294234d17 100644
--- a/apps/api/src/auth/ability.factory.ts
+++ b/apps/api/src/auth/ability.factory.ts
@@ -5,6 +5,7 @@ import { Injectable } from '@nestjs/common';
import type { TokenPayload } from '@opendatacapture/schemas/auth';
import { isGroupScopableSubject } from '@opendatacapture/schemas/core';
import type { GroupScopableSubjectName } from '@opendatacapture/schemas/core';
+import type { PermissionSetRule } from '@opendatacapture/schemas/permission-set';
import type { PrismaModelWhereInputMap } from '@/core/prisma';
@@ -13,25 +14,30 @@ import { createAppAbility, detectAppSubject } from './ability.utils';
import type { AppAbility, Permission } from './auth.types';
/**
- * The condition confining a per-user permission to one group, per model that can carry one. The
- * fields are the same ones the group manager rules below use. Typed over the schema's list, so a
- * subject made scopable there fails to compile here until its group field is named.
+ * The condition confining a granted permission to some groups, per model that can carry one: one
+ * group for a per-user grant, the holder's groups for a permission set rule. The fields are the same
+ * ones the group manager rules below use. Typed over the schema's list, so a subject made scopable
+ * there fails to compile here until its group field is named.
*/
const GROUP_SCOPED_CONDITIONS = {
- Assignment: (groupId) => ({ groupId: { in: [groupId] } }),
- Group: (groupId) => ({ id: { in: [groupId] } }),
- InstrumentRecord: (groupId) => ({ groupId: { in: [groupId] } }),
- InstrumentRepo: (groupId) => ({ groupIds: { hasSome: [groupId] } }),
- Session: (groupId) => ({ groupId: { in: [groupId] } }),
- Subject: (groupId) => ({ groupIds: { hasSome: [groupId] } }),
- User: (groupId) => ({ groupIds: { hasSome: [groupId] } })
-} satisfies { [K in GroupScopableSubjectName]: (groupId: string) => NonNullable };
+ Assignment: (groupIds) => ({ groupId: { in: groupIds } }),
+ Group: (groupIds) => ({ id: { in: groupIds } }),
+ InstrumentRecord: (groupIds) => ({ groupId: { in: groupIds } }),
+ InstrumentRepo: (groupIds) => ({ groupIds: { hasSome: groupIds } }),
+ Session: (groupIds) => ({ groupId: { in: groupIds } }),
+ Subject: (groupIds) => ({ groupIds: { hasSome: groupIds } }),
+ User: (groupIds) => ({ groupIds: { hasSome: groupIds } })
+} satisfies { [K in GroupScopableSubjectName]: (groupIds: string[]) => NonNullable };
@Injectable()
export class AbilityFactory {
constructor(private readonly loggingService: LoggingService) {}
- createForPayload(payload: Omit): AppAbility {
+ /**
+ * `permissionSetRules` are the rules of the set the user holds, read at sign-in rather than carried
+ * on the payload. Required, so the caller cannot leave a holder with only their base level.
+ */
+ createForPayload(payload: Omit, permissionSetRules: PermissionSetRule[]): AppAbility {
this.loggingService.verbose({
message: 'Creating Ability From Payload',
payload
@@ -87,11 +93,22 @@ export class AbilityFactory {
ability.can('read', 'User', { id: payload.id });
break;
}
+ permissionSetRules.forEach(({ action, scope, subject }) => {
+ if (scope === 'ALL_GROUPS') {
+ ability.can(action, subject);
+ } else if (isGroupScopableSubject(subject)) {
+ ability.can(action, subject, GROUP_SCOPED_CONDITIONS[subject](groupIds));
+ } else {
+ // As below: the schema refuses this shape at the boundary, and neither reading of a stored one
+ // is what the set's author asked for.
+ throw new Error(`Cannot confine permission set rule '${action} ${subject}' to the holder's groups`);
+ }
+ });
payload.additionalPermissions?.forEach(({ action, groupId, subject }) => {
if (groupId === null) {
ability.can(action, subject);
} else if (isGroupScopableSubject(subject)) {
- ability.can(action, subject, GROUP_SCOPED_CONDITIONS[subject](groupId));
+ ability.can(action, subject, GROUP_SCOPED_CONDITIONS[subject]([groupId]));
} else {
// The schema refuses this shape at the boundary; a stored row that has it anyway is applied
// under neither reading, since either one silently changes what the grant means.
diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts
index cb0ad4ff2..c29843f90 100644
--- a/apps/api/src/auth/auth.service.ts
+++ b/apps/api/src/auth/auth.service.ts
@@ -3,7 +3,7 @@ import type { RequestUser } from '@douglasneuroinformatics/libnest';
import { ForbiddenException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import type { $LoginCredentials, TokenPayload } from '@opendatacapture/schemas/auth';
-import type { Group, User } from '@prisma/client';
+import type { Group, PermissionSet, User } from '@prisma/client';
import { AuditLogger } from '@/audit/audit.logger';
import { UsersService } from '@/users/users.service';
@@ -41,6 +41,7 @@ export class AuthService {
async login(credentials: $LoginCredentials): Promise<{ accessToken: string }> {
let user: User & {
groups: Group[];
+ permissionSet: null | PermissionSet;
};
try {
user = await this.usersService.findByUsername(credentials.username, { includeHashedPassword: true });
@@ -77,7 +78,9 @@ export class AuthService {
username: user.username
};
- const ability = this.abilityFactory.createForPayload(tokenPayload);
+ // The set's rules are resolved here rather than carried on the payload: what they grant is signed
+ // into `permissions` below, so the token grows only as much as equivalent custom grants would.
+ const ability = this.abilityFactory.createForPayload(tokenPayload, user.permissionSet?.rules ?? []);
const accessToken = await this.jwtService.signAsync(
{ ...tokenPayload, permissions: ability.rules },
diff --git a/apps/api/src/core/prisma.ts b/apps/api/src/core/prisma.ts
index fa55d8f48..aaf22d2a4 100644
--- a/apps/api/src/core/prisma.ts
+++ b/apps/api/src/core/prisma.ts
@@ -72,6 +72,7 @@ const DATABASE_INDEXES: DatabaseIndex[] = [
{ collection: 'InstrumentRepoModel', fields: ['url'], unique: true },
{ collection: 'InstrumentRepoSyncLogModel', fields: ['createdAt'] },
{ collection: 'InstrumentRepoSyncLogModel', fields: ['repoId', 'createdAt'] },
+ { collection: 'PermissionSetModel', fields: ['name'], unique: true },
{ collection: 'SessionModel', fields: ['groupId', 'date'] },
{ collection: 'SessionModel', fields: ['subjectId'] },
{ collection: 'SubjectModel', fields: ['groupIds'] },
diff --git a/apps/api/src/groups/__tests__/groups.controller.test.ts b/apps/api/src/groups/__tests__/groups.controller.test.ts
index fb573e46d..7f1723695 100644
--- a/apps/api/src/groups/__tests__/groups.controller.test.ts
+++ b/apps/api/src/groups/__tests__/groups.controller.test.ts
@@ -60,18 +60,21 @@ describe('GroupsController', () => {
// `create Group` declaration admitted every group manager (#1468).
describe('route access for create', () => {
const abilityFor = (basePermissionLevel: BasePermissionLevel) => {
- return new AbilityFactory(MockFactory.createMock(LoggingService) as unknown as LoggingService).createForPayload({
- basePermissionLevel,
- firstName: 'Test',
- groups: [{ id: 'group-1' }] as Group[],
- id: 'user-1',
- kind: 'login',
- lastName: 'User',
- // This suite is testing basePermissionLevel-driven access, not the forced-reset ability,
- // which would short-circuit every case below to `read User self` regardless of the role.
- mustResetPassword: false,
- username: 'test-user'
- });
+ return new AbilityFactory(MockFactory.createMock(LoggingService) as unknown as LoggingService).createForPayload(
+ {
+ basePermissionLevel,
+ firstName: 'Test',
+ groups: [{ id: 'group-1' }] as Group[],
+ id: 'user-1',
+ kind: 'login',
+ lastName: 'User',
+ // This suite is testing basePermissionLevel-driven access, not the forced-reset ability,
+ // which would short-circuit every case below to `read User self` regardless of the role.
+ mustResetPassword: false,
+ username: 'test-user'
+ },
+ []
+ );
};
// `@RouteAccess` stores its metadata on the handler itself, which is what the guard reads
diff --git a/apps/api/src/instrument-records/__tests__/instrument-records.service.test.ts b/apps/api/src/instrument-records/__tests__/instrument-records.service.test.ts
index 39c5062d6..c64e3a2fa 100644
--- a/apps/api/src/instrument-records/__tests__/instrument-records.service.test.ts
+++ b/apps/api/src/instrument-records/__tests__/instrument-records.service.test.ts
@@ -909,7 +909,8 @@ describe('InstrumentRecordsService', () => {
id: 'user-1',
lastName: 'User',
username: 'test-user'
- } as any
+ } as any,
+ []
);
};
diff --git a/apps/api/src/instruments/__tests__/instruments.controller.test.ts b/apps/api/src/instruments/__tests__/instruments.controller.test.ts
index afcf27c20..b18ebacb3 100644
--- a/apps/api/src/instruments/__tests__/instruments.controller.test.ts
+++ b/apps/api/src/instruments/__tests__/instruments.controller.test.ts
@@ -79,16 +79,19 @@ describe('InstrumentsController', () => {
// and delete their own group's series, which the guard would not tell apart from any other.
describe.each(['findSeriesOverview', 'updateSeriesArchive'] as const)('route access for %s', (handlerName) => {
const abilityFor = (basePermissionLevel: BasePermissionLevel) => {
- return new AbilityFactory(MockFactory.createMock(LoggingService) as unknown as LoggingService).createForPayload({
- basePermissionLevel,
- firstName: 'Test',
- groups: [{ id: 'group-1' }] as Group[],
- id: 'user-1',
- kind: 'login',
- lastName: 'User',
- mustResetPassword: false,
- username: 'test-user'
- });
+ return new AbilityFactory(MockFactory.createMock(LoggingService) as unknown as LoggingService).createForPayload(
+ {
+ basePermissionLevel,
+ firstName: 'Test',
+ groups: [{ id: 'group-1' }] as Group[],
+ id: 'user-1',
+ kind: 'login',
+ lastName: 'User',
+ mustResetPassword: false,
+ username: 'test-user'
+ },
+ []
+ );
};
const { action, subject } = new Reflector().get(
ROUTE_ACCESS_METADATA_KEY,
diff --git a/apps/api/src/instruments/__tests__/instruments.service.test.ts b/apps/api/src/instruments/__tests__/instruments.service.test.ts
index 8502319e7..84ae054e8 100644
--- a/apps/api/src/instruments/__tests__/instruments.service.test.ts
+++ b/apps/api/src/instruments/__tests__/instruments.service.test.ts
@@ -1215,11 +1215,14 @@ describe('InstrumentsService', () => {
// `create` but not `read`, and this route is gated on `read Instrument`, which they do hold.
it('should resolve for a caller who may read no records, rather than failing the request', async () => {
const abilityFactory = new AbilityFactory(MockFactory.createMock(LoggingService) as unknown as LoggingService);
- const ability = abilityFactory.createForPayload({
- basePermissionLevel: 'STANDARD',
- groups: [{ id: 'group-1' }],
- id: 'user-1'
- } as any);
+ const ability = abilityFactory.createForPayload(
+ {
+ basePermissionLevel: 'STANDARD',
+ groups: [{ id: 'group-1' }],
+ id: 'user-1'
+ } as any,
+ []
+ );
await expect(instrumentsService.find({ subjectId: 'subject-1' }, { ability })).resolves.toStrictEqual([]);
diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts
index baff0327c..37a3b49e9 100644
--- a/apps/api/src/main.ts
+++ b/apps/api/src/main.ts
@@ -11,6 +11,7 @@ import { InstrumentRecordsModule } from './instrument-records/instrument-records
import { InstrumentReposModule } from './instrument-repos/instrument-repos.module';
import { InstrumentsModule } from './instruments/instruments.module';
import { MailModule } from './mail/mail.module';
+import { PermissionSetsModule } from './permission-sets/permission-sets.module';
import { SessionsModule } from './sessions/sessions.module';
import { SetupModule } from './setup/setup.module';
import { StorageModule } from './storage/storage.module';
@@ -47,6 +48,7 @@ export default AppFactory.create({
InstrumentReposModule,
InstrumentsModule,
MailModule,
+ PermissionSetsModule,
PrismaModule.forRootAsync({
useClass: PrismaModuleOptionsFactory
}),
diff --git a/apps/api/src/permission-sets/__tests__/permission-sets.controller.test.ts b/apps/api/src/permission-sets/__tests__/permission-sets.controller.test.ts
new file mode 100644
index 000000000..1d5436b81
--- /dev/null
+++ b/apps/api/src/permission-sets/__tests__/permission-sets.controller.test.ts
@@ -0,0 +1,108 @@
+import { LoggingService } from '@douglasneuroinformatics/libnest';
+import { MockFactory } from '@douglasneuroinformatics/libnest/testing';
+import type { MockedInstance } from '@douglasneuroinformatics/libnest/testing';
+import { Reflector } from '@nestjs/core';
+import { Test } from '@nestjs/testing';
+import type { Permissions } from '@opendatacapture/schemas/core';
+import type { Group } from '@opendatacapture/schemas/group';
+import type { PermissionSetRule } from '@opendatacapture/schemas/permission-set';
+import type { BasePermissionLevel } from '@opendatacapture/schemas/user';
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import { AbilityFactory } from '@/auth/ability.factory';
+import { createAppAbility } from '@/auth/ability.utils';
+import { ROUTE_ACCESS_METADATA_KEY } from '@/core/decorators/route-access.decorator';
+import type { ProtectedRoutePermissionSet } from '@/core/decorators/route-access.decorator';
+
+import { PermissionSetsController } from '../permission-sets.controller';
+import { PermissionSetsService } from '../permission-sets.service';
+
+const ability = createAppAbility([{ action: 'manage', subject: 'all' }]);
+
+const RULES: PermissionSetRule[] = [{ action: 'read', scope: 'HOLDER_GROUPS', subject: 'Subject' }];
+
+describe('PermissionSetsController', () => {
+ let permissionSetsController: PermissionSetsController;
+ let permissionSetsService: MockedInstance;
+
+ beforeEach(async () => {
+ const moduleRef = await Test.createTestingModule({
+ controllers: [PermissionSetsController],
+ providers: [MockFactory.createForService(PermissionSetsService)]
+ }).compile();
+ permissionSetsController = moduleRef.get(PermissionSetsController);
+ permissionSetsService = moduleRef.get(PermissionSetsService);
+ });
+
+ it('should pass a new set to the service as received, since the body is already validated', async () => {
+ await permissionSetsController.create({ name: 'Research Assistant', rules: RULES });
+ expect(permissionSetsService.create).toHaveBeenCalledWith({ name: 'Research Assistant', rules: RULES });
+ });
+
+ it('should forward the caller ability when listing sets, so the query is scoped', async () => {
+ await permissionSetsController.findAll(ability);
+ expect(permissionSetsService.findAll).toHaveBeenCalledWith({ ability });
+ });
+
+ it('should forward the caller ability when reading a set, so the query is scoped', async () => {
+ await permissionSetsController.findById('set-1', ability);
+ expect(permissionSetsService.findById).toHaveBeenCalledWith('set-1', { ability });
+ });
+
+ it('should forward the caller ability when updating a set, so the write is scoped', async () => {
+ await permissionSetsController.updateById('set-1', { name: 'Renamed' }, ability);
+ expect(permissionSetsService.updateById).toHaveBeenCalledWith('set-1', { name: 'Renamed' }, { ability });
+ });
+
+ it('should forward the caller ability when deleting a set, so the write is scoped', async () => {
+ await permissionSetsController.deleteById('set-1', ability);
+ expect(permissionSetsService.deleteById).toHaveBeenCalledWith('set-1', { ability });
+ });
+
+ // What `JwtAuthGuard` itself computes for each route: the declared access, evaluated against a whole
+ // ability. Editing a set is a grant to each of its holders, so nothing short of an administrator may.
+ describe('route access', () => {
+ const HANDLERS = ['create', 'deleteById', 'findAll', 'findById', 'updateById'] as const;
+
+ const abilityFor = (basePermissionLevel: BasePermissionLevel, additionalPermissions: Permissions = []) => {
+ return new AbilityFactory(MockFactory.createMock(LoggingService) as unknown as LoggingService).createForPayload(
+ {
+ additionalPermissions,
+ basePermissionLevel,
+ firstName: 'Test',
+ groups: [{ id: 'group-1' }] as Group[],
+ id: 'user-1',
+ kind: 'login',
+ lastName: 'User',
+ mustResetPassword: false,
+ username: 'test-user'
+ },
+ []
+ );
+ };
+
+ const declaredAccess = (handler: (typeof HANDLERS)[number]) => {
+ return new Reflector().get(
+ ROUTE_ACCESS_METADATA_KEY,
+ Object.getOwnPropertyDescriptor(PermissionSetsController.prototype, handler)!.value
+ );
+ };
+
+ it.each(HANDLERS)(
+ 'should refuse %s to a group manager granted every action but manage on everything, which a set may hold',
+ (handler) => {
+ const { action, subject } = declaredAccess(handler);
+ const grantee = abilityFor(
+ 'GROUP_MANAGER',
+ (['create', 'delete', 'read', 'update'] as const).map((action) => ({ action, groupId: null, subject: 'all' }))
+ );
+ expect(grantee.can(action, subject)).toBe(false);
+ }
+ );
+
+ it.each(HANDLERS)('should allow %s to an administrator', (handler) => {
+ const { action, subject } = declaredAccess(handler);
+ expect(abilityFor('ADMIN').can(action, subject)).toBe(true);
+ });
+ });
+});
diff --git a/apps/api/src/permission-sets/__tests__/permission-sets.service.test.ts b/apps/api/src/permission-sets/__tests__/permission-sets.service.test.ts
new file mode 100644
index 000000000..fdecb733a
--- /dev/null
+++ b/apps/api/src/permission-sets/__tests__/permission-sets.service.test.ts
@@ -0,0 +1,134 @@
+import type { Model } from '@douglasneuroinformatics/libnest';
+import { getModelToken } from '@douglasneuroinformatics/libnest';
+import { MockFactory } from '@douglasneuroinformatics/libnest/testing';
+import type { MockedInstance } from '@douglasneuroinformatics/libnest/testing';
+import { ConflictException, NotFoundException } from '@nestjs/common';
+import { Test } from '@nestjs/testing';
+import type { PermissionSetRule } from '@opendatacapture/schemas/permission-set';
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import { accessibleQuery, createAppAbility } from '@/auth/ability.utils';
+
+import { PermissionSetsService } from '../permission-sets.service';
+
+const ability = createAppAbility([{ action: 'manage', subject: 'all' }]);
+
+const RULES: PermissionSetRule[] = [{ action: 'read', scope: 'HOLDER_GROUPS', subject: 'Subject' }];
+
+const STORED_SET = { _count: { users: 3 }, id: 'set-1', name: 'Research Assistant', rules: RULES };
+
+describe('PermissionSetsService', () => {
+ let permissionSetsService: PermissionSetsService;
+ let permissionSetModel: MockedInstance>;
+
+ beforeEach(async () => {
+ const moduleRef = await Test.createTestingModule({
+ providers: [PermissionSetsService, MockFactory.createForModelToken(getModelToken('PermissionSet'))]
+ }).compile();
+ permissionSetModel = moduleRef.get(getModelToken('PermissionSet'));
+ permissionSetsService = moduleRef.get(PermissionSetsService);
+ permissionSetModel.exists.mockResolvedValue(false);
+ permissionSetModel.findFirst.mockResolvedValue(STORED_SET);
+ });
+
+ describe('create', () => {
+ it('should store the name and rules', async () => {
+ await permissionSetsService.create({ name: 'Research Assistant', rules: RULES });
+ expect(permissionSetModel.create.mock.lastCall?.[0]).toEqual({
+ data: { name: 'Research Assistant', rules: RULES }
+ });
+ });
+
+ it('should refuse a name another set already has, before writing anything', async () => {
+ permissionSetModel.exists.mockResolvedValue(true);
+ await expect(permissionSetsService.create({ name: 'Research Assistant', rules: RULES })).rejects.toThrow(
+ ConflictException
+ );
+ expect(permissionSetModel.create).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('findAll', () => {
+ it('should return each set with the number of users holding it', async () => {
+ permissionSetModel.findMany.mockResolvedValue([STORED_SET]);
+ await expect(permissionSetsService.findAll({ ability })).resolves.toEqual([
+ { holderCount: 3, id: 'set-1', name: 'Research Assistant', rules: RULES }
+ ]);
+ });
+
+ it('should scope the listing to the caller ability', async () => {
+ permissionSetModel.findMany.mockResolvedValue([]);
+ await permissionSetsService.findAll({ ability });
+ expect(permissionSetModel.findMany.mock.lastCall?.[0]).toMatchObject({
+ where: accessibleQuery(ability, 'read', 'PermissionSet')
+ });
+ });
+ });
+
+ describe('findById', () => {
+ it('should return the set with the number of users holding it, found within the caller read scope', async () => {
+ await expect(permissionSetsService.findById('set-1', { ability })).resolves.toEqual({
+ holderCount: 3,
+ id: 'set-1',
+ name: 'Research Assistant',
+ rules: RULES
+ });
+ expect(permissionSetModel.findFirst.mock.lastCall?.[0]).toMatchObject({
+ where: { AND: [accessibleQuery(ability, 'read', 'PermissionSet')], id: 'set-1' }
+ });
+ });
+
+ it('should throw when the set cannot be found', async () => {
+ permissionSetModel.findFirst.mockResolvedValue(null);
+ await expect(permissionSetsService.findById('set-1')).rejects.toThrow(NotFoundException);
+ });
+ });
+
+ describe('updateById', () => {
+ it('should replace the rules wholesale, as a composite list must be on MongoDB', async () => {
+ await permissionSetsService.updateById('set-1', { rules: RULES }, { ability });
+ expect(permissionSetModel.update.mock.lastCall?.[0]).toMatchObject({
+ data: { rules: { set: RULES } },
+ where: { AND: [accessibleQuery(ability, 'update', 'PermissionSet')], id: 'set-1' }
+ });
+ });
+
+ it('should leave the rules alone when they are not being changed', async () => {
+ await permissionSetsService.updateById('set-1', { name: 'Renamed' });
+ expect(permissionSetModel.update.mock.lastCall?.[0].data).toEqual({ name: 'Renamed', rules: undefined });
+ });
+
+ it('should refuse a rename to a name another set already has', async () => {
+ permissionSetModel.exists.mockResolvedValue(true);
+ await expect(permissionSetsService.updateById('set-1', { name: 'Taken' })).rejects.toThrow(ConflictException);
+ expect(permissionSetModel.update).not.toHaveBeenCalled();
+ });
+
+ it('should not look for a collision when the name is unchanged, since it would always find this set', async () => {
+ await permissionSetsService.updateById('set-1', { name: 'Research Assistant' });
+ expect(permissionSetModel.exists).not.toHaveBeenCalled();
+ expect(permissionSetModel.update).toHaveBeenCalledOnce();
+ });
+
+ it('should throw when the set cannot be found, before writing anything', async () => {
+ permissionSetModel.findFirst.mockResolvedValue(null);
+ await expect(permissionSetsService.updateById('set-1', { name: 'Renamed' })).rejects.toThrow(NotFoundException);
+ expect(permissionSetModel.update).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('deleteById', () => {
+ it('should delete the set within the caller delete scope', async () => {
+ await permissionSetsService.deleteById('set-1', { ability });
+ expect(permissionSetModel.delete.mock.lastCall?.[0]).toEqual({
+ where: { AND: [accessibleQuery(ability, 'delete', 'PermissionSet')], id: 'set-1' }
+ });
+ });
+
+ it('should throw when the set cannot be found, rather than failing inside the delete', async () => {
+ permissionSetModel.findFirst.mockResolvedValue(null);
+ await expect(permissionSetsService.deleteById('set-1')).rejects.toThrow(NotFoundException);
+ expect(permissionSetModel.delete).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/apps/api/src/permission-sets/permission-sets.controller.ts b/apps/api/src/permission-sets/permission-sets.controller.ts
new file mode 100644
index 000000000..93df6a4a0
--- /dev/null
+++ b/apps/api/src/permission-sets/permission-sets.controller.ts
@@ -0,0 +1,56 @@
+import { ApiOperation, CurrentUser, ValidObjectIdPipe } from '@douglasneuroinformatics/libnest';
+import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
+import { $CreatePermissionSetData, $UpdatePermissionSetData } from '@opendatacapture/schemas/permission-set';
+
+import type { AppAbility } from '@/auth/auth.types';
+import { ADMIN_ONLY, RouteAccess } from '@/core/decorators/route-access.decorator';
+
+import { PermissionSetsService } from './permission-sets.service';
+
+/**
+ * Every route is admin-only, reads included. Editing a set changes what each of its holders may do,
+ * so a write is a grant, and only an administrator may grant; holders have no use for reading it.
+ */
+@Controller('permission-sets')
+export class PermissionSetsController {
+ constructor(private readonly permissionSetsService: PermissionSetsService) {}
+
+ @ApiOperation({ summary: 'Create Permission Set' })
+ @Post()
+ @RouteAccess(ADMIN_ONLY)
+ create(@Body() data: $CreatePermissionSetData) {
+ return this.permissionSetsService.create(data);
+ }
+
+ @ApiOperation({ summary: 'Delete Permission Set' })
+ @Delete(':id')
+ @RouteAccess(ADMIN_ONLY)
+ deleteById(@Param('id', ValidObjectIdPipe) id: string, @CurrentUser('ability') ability: AppAbility) {
+ return this.permissionSetsService.deleteById(id, { ability });
+ }
+
+ @ApiOperation({ summary: 'Get All Permission Sets' })
+ @Get()
+ @RouteAccess(ADMIN_ONLY)
+ findAll(@CurrentUser('ability') ability: AppAbility) {
+ return this.permissionSetsService.findAll({ ability });
+ }
+
+ @ApiOperation({ summary: 'Get Permission Set' })
+ @Get(':id')
+ @RouteAccess(ADMIN_ONLY)
+ findById(@Param('id', ValidObjectIdPipe) id: string, @CurrentUser('ability') ability: AppAbility) {
+ return this.permissionSetsService.findById(id, { ability });
+ }
+
+ @ApiOperation({ summary: 'Update Permission Set' })
+ @Patch(':id')
+ @RouteAccess(ADMIN_ONLY)
+ updateById(
+ @Param('id', ValidObjectIdPipe) id: string,
+ @Body() update: $UpdatePermissionSetData,
+ @CurrentUser('ability') ability: AppAbility
+ ) {
+ return this.permissionSetsService.updateById(id, update, { ability });
+ }
+}
diff --git a/apps/api/src/permission-sets/permission-sets.module.ts b/apps/api/src/permission-sets/permission-sets.module.ts
new file mode 100644
index 000000000..756d38498
--- /dev/null
+++ b/apps/api/src/permission-sets/permission-sets.module.ts
@@ -0,0 +1,11 @@
+import { Module } from '@nestjs/common';
+
+import { PermissionSetsController } from './permission-sets.controller';
+import { PermissionSetsService } from './permission-sets.service';
+
+@Module({
+ controllers: [PermissionSetsController],
+ exports: [PermissionSetsService],
+ providers: [PermissionSetsService]
+})
+export class PermissionSetsModule {}
diff --git a/apps/api/src/permission-sets/permission-sets.service.ts b/apps/api/src/permission-sets/permission-sets.service.ts
new file mode 100644
index 000000000..35fcee5e2
--- /dev/null
+++ b/apps/api/src/permission-sets/permission-sets.service.ts
@@ -0,0 +1,75 @@
+import { InjectModel } from '@douglasneuroinformatics/libnest';
+import type { Model } from '@douglasneuroinformatics/libnest';
+import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
+import { $CreatePermissionSetData, $UpdatePermissionSetData } from '@opendatacapture/schemas/permission-set';
+
+import { accessibleQuery } from '@/auth/ability.utils';
+import type { EntityOperationOptions } from '@/core/types';
+
+const INCLUDE_HOLDER_COUNT = { _count: { select: { users: true } } } as const;
+
+function withHolderCount({
+ _count,
+ ...permissionSet
+}: TPermissionSet) {
+ return { ...permissionSet, holderCount: _count.users };
+}
+
+@Injectable()
+export class PermissionSetsService {
+ constructor(@InjectModel('PermissionSet') private readonly permissionSetModel: Model<'PermissionSet'>) {}
+
+ async create({ name, rules }: $CreatePermissionSetData) {
+ await this.assertNameIsFree(name);
+ return this.permissionSetModel.create({
+ data: { name, rules }
+ });
+ }
+
+ /** Every holder's `permissionSetId` is cleared by the relation's `onDelete: SetNull`. */
+ async deleteById(id: string, { ability }: EntityOperationOptions = {}) {
+ await this.findById(id, { ability });
+ return this.permissionSetModel.delete({
+ where: { AND: [accessibleQuery(ability, 'delete', 'PermissionSet')], id }
+ });
+ }
+
+ async findAll({ ability }: EntityOperationOptions = {}) {
+ const permissionSets = await this.permissionSetModel.findMany({
+ include: INCLUDE_HOLDER_COUNT,
+ orderBy: { name: 'asc' },
+ where: accessibleQuery(ability, 'read', 'PermissionSet')
+ });
+ return permissionSets.map(withHolderCount);
+ }
+
+ async findById(id: string, { ability }: EntityOperationOptions = {}) {
+ const permissionSet = await this.permissionSetModel.findFirst({
+ include: INCLUDE_HOLDER_COUNT,
+ where: { AND: [accessibleQuery(ability, 'read', 'PermissionSet')], id }
+ });
+ if (!permissionSet) {
+ throw new NotFoundException(`Failed to find permission set with ID: ${id}`);
+ }
+ return withHolderCount(permissionSet);
+ }
+
+ async updateById(id: string, { name, rules }: $UpdatePermissionSetData, { ability }: EntityOperationOptions = {}) {
+ const permissionSet = await this.findById(id, { ability });
+ if (name !== undefined && name !== permissionSet.name) {
+ await this.assertNameIsFree(name);
+ }
+ return this.permissionSetModel.update({
+ // Composite list fields must be replaced via `set` in the MongoDB connector.
+ data: { name, rules: rules ? { set: rules } : undefined },
+ where: { AND: [accessibleQuery(ability, 'update', 'PermissionSet')], id }
+ });
+ }
+
+ /** Unscoped on purpose: a name must be unique across every set, not only those the caller can read. */
+ private async assertNameIsFree(name: string) {
+ if (await this.permissionSetModel.exists({ name })) {
+ throw new ConflictException(`Permission set with name '${name}' already exists!`);
+ }
+ }
+}
diff --git a/apps/api/src/subjects/__tests__/subjects.service.test.ts b/apps/api/src/subjects/__tests__/subjects.service.test.ts
index b48033bb4..15f2c5b88 100644
--- a/apps/api/src/subjects/__tests__/subjects.service.test.ts
+++ b/apps/api/src/subjects/__tests__/subjects.service.test.ts
@@ -248,16 +248,19 @@ describe('SubjectsService', () => {
// for the subject at all, which escapes as a 500 rather than the empty list they should see.
it('should return an empty list for a caller who may read no records, rather than throwing', async () => {
const abilityFactory = new AbilityFactory(MockFactory.createMock(LoggingService) as unknown as LoggingService);
- const ability = abilityFactory.createForPayload({
- basePermissionLevel: 'STANDARD',
- firstName: null,
- groups: [],
- id: 'user-1',
- kind: 'login',
- lastName: null,
- mustResetPassword: false,
- username: 'standard-user'
- });
+ const ability = abilityFactory.createForPayload(
+ {
+ basePermissionLevel: 'STANDARD',
+ firstName: null,
+ groups: [],
+ id: 'user-1',
+ kind: 'login',
+ lastName: null,
+ mustResetPassword: false,
+ username: 'standard-user'
+ },
+ []
+ );
subjectModel.findMany.mockResolvedValueOnce([]);
await expect(subjectsService.find({ hasRecord: true }, { ability })).resolves.toStrictEqual([]);
diff --git a/apps/api/src/users/__tests__/users.controller.test.ts b/apps/api/src/users/__tests__/users.controller.test.ts
index 8ec067db5..4cbcf66ba 100644
--- a/apps/api/src/users/__tests__/users.controller.test.ts
+++ b/apps/api/src/users/__tests__/users.controller.test.ts
@@ -219,10 +219,23 @@ describe('UsersController', () => {
});
describe('updatePermissions', () => {
- it('should forward the caller ability, so the write is scoped', async () => {
+ it('should forward custom grants with the caller ability, so the write is scoped', async () => {
const permissions: Permissions = [{ action: 'read', groupId: 'group-1', subject: 'Subject' }];
- await usersController.updatePermissions('user-1', { permissions }, ability);
- expect(usersService.updatePermissions).toHaveBeenCalledWith('user-1', permissions, { ability });
+ await usersController.updatePermissions('user-1', { kind: 'custom', permissions }, ability);
+ expect(usersService.updatePermissions).toHaveBeenCalledWith(
+ 'user-1',
+ { kind: 'custom', permissions },
+ { ability }
+ );
+ });
+
+ it('should forward a permission set with the caller ability, so the write is scoped', async () => {
+ await usersController.updatePermissions('user-1', { kind: 'permissionSet', permissionSetId: 'set-1' }, ability);
+ expect(usersService.updatePermissions).toHaveBeenCalledWith(
+ 'user-1',
+ { kind: 'permissionSet', permissionSetId: 'set-1' },
+ { ability }
+ );
});
});
@@ -233,17 +246,20 @@ describe('UsersController', () => {
const WRITE_HANDLERS = ['archiveById', 'create', 'unarchiveById', 'updateById', 'updatePermissions'] as const;
const abilityFor = (basePermissionLevel: BasePermissionLevel, additionalPermissions: Permissions = []) => {
- return new AbilityFactory(MockFactory.createMock(LoggingService) as unknown as LoggingService).createForPayload({
- additionalPermissions,
- basePermissionLevel,
- firstName: 'Test',
- groups: [{ id: 'group-1' }] as Group[],
- id: 'user-1',
- kind: 'login',
- lastName: 'User',
- mustResetPassword: false,
- username: 'test-user'
- });
+ return new AbilityFactory(MockFactory.createMock(LoggingService) as unknown as LoggingService).createForPayload(
+ {
+ additionalPermissions,
+ basePermissionLevel,
+ firstName: 'Test',
+ groups: [{ id: 'group-1' }] as Group[],
+ id: 'user-1',
+ kind: 'login',
+ lastName: 'User',
+ mustResetPassword: false,
+ username: 'test-user'
+ },
+ []
+ );
};
const declaredAccess = (handler: (typeof WRITE_HANDLERS)[number]) => {
diff --git a/apps/api/src/users/__tests__/users.service.test.ts b/apps/api/src/users/__tests__/users.service.test.ts
index 78062ad45..7ab09402d 100644
--- a/apps/api/src/users/__tests__/users.service.test.ts
+++ b/apps/api/src/users/__tests__/users.service.test.ts
@@ -6,6 +6,7 @@ import { estimatePasswordStrength } from '@douglasneuroinformatics/libpasswd';
import { BadRequestException, ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import type { Permissions } from '@opendatacapture/schemas/core';
+import type { $UpdateUserPermissionsData } from '@opendatacapture/schemas/user';
import { pwnedPassword } from 'hibp';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Mock } from 'vitest';
@@ -14,6 +15,7 @@ import { AuditLogger } from '@/audit/audit.logger';
import { accessibleQuery, createAppAbility } from '@/auth/ability.utils';
import { GroupsService } from '../../groups/groups.service';
+import { PermissionSetsService } from '../../permission-sets/permission-sets.service';
import { UsersService } from '../users.service';
vi.mock('hibp', () => ({ pwnedPassword: vi.fn() }));
@@ -38,6 +40,7 @@ describe('UsersService', () => {
let userModel: MockedInstance>;
let cryptoService: MockedInstance;
let groupsService: MockedInstance;
+ let permissionSetsService: MockedInstance;
beforeEach(async () => {
vi.clearAllMocks();
@@ -47,13 +50,15 @@ describe('UsersService', () => {
MockFactory.createForModelToken(getModelToken('User')),
MockFactory.createForService(AuditLogger),
MockFactory.createForService(CryptoService),
- MockFactory.createForService(GroupsService)
+ MockFactory.createForService(GroupsService),
+ MockFactory.createForService(PermissionSetsService)
]
}).compile();
userModel = moduleRef.get(getModelToken('User'));
usersService = moduleRef.get(UsersService);
cryptoService = moduleRef.get(CryptoService);
groupsService = moduleRef.get(GroupsService);
+ permissionSetsService = moduleRef.get(PermissionSetsService);
userModel.exists.mockResolvedValue(false);
userModel.create.mockResolvedValue({});
@@ -143,6 +148,24 @@ describe('UsersService', () => {
await usersService.create({ ...baseUser });
expect(userModel.create.mock.lastCall?.[0].data.additionalPermissions).toEqual([]);
});
+
+ it('should start the user with no permission set when none is requested', async () => {
+ await usersService.create({ ...baseUser });
+ expect(userModel.create.mock.lastCall?.[0].data.permissionSet).toBeUndefined();
+ expect(permissionSetsService.findById).not.toHaveBeenCalled();
+ });
+
+ it('should link the requested permission set, looked up with the caller options', async () => {
+ await usersService.create({ ...baseUser, permissionSetId: 'set-1' }, { ability: admin.ability });
+ expect(permissionSetsService.findById).toHaveBeenCalledWith('set-1', { ability: admin.ability });
+ expect(userModel.create.mock.lastCall?.[0].data.permissionSet).toEqual({ connect: { id: 'set-1' } });
+ });
+
+ it('should propagate a permission set lookup that finds nothing, so the user is never created', async () => {
+ permissionSetsService.findById.mockRejectedValue(new NotFoundException());
+ await expect(usersService.create({ ...baseUser, permissionSetId: 'set-1' })).rejects.toThrow(NotFoundException);
+ expect(userModel.create).not.toHaveBeenCalled();
+ });
});
describe('find', () => {
@@ -313,6 +336,19 @@ describe('UsersService', () => {
expect(userModel.findFirst).not.toHaveBeenCalled();
});
+ it('should remove the permission set of a user promoted to administrator, who already holds every permission', async () => {
+ await usersService.updateById('user-1', { basePermissionLevel: 'ADMIN' }, admin);
+ expect(userModel.update.mock.lastCall?.[0].data.permissionSet).toEqual({ disconnect: true });
+ });
+
+ it.each(['GROUP_MANAGER', 'STANDARD', undefined] as const)(
+ 'should leave the permission set alone when the level becomes %s',
+ async (basePermissionLevel) => {
+ await usersService.updateById('user-1', { basePermissionLevel, firstName: 'Janet' }, admin);
+ expect(userModel.update.mock.lastCall?.[0].data.permissionSet).toBeUndefined();
+ }
+ );
+
it('should let an administrator disable and demote another user', async () => {
await usersService.updateById('user-1', { basePermissionLevel: 'STANDARD', disabled: true }, admin);
expect(userModel.update.mock.lastCall?.[0].data).toMatchObject({
@@ -357,42 +393,85 @@ describe('UsersService', () => {
describe('updatePermissions', () => {
beforeEach(() => {
- userModel.findFirst.mockResolvedValue({ groupIds: ['group-1'], id: 'user-1' });
+ userModel.findFirst.mockResolvedValue({ basePermissionLevel: 'STANDARD', groupIds: ['group-1'], id: 'user-1' });
userModel.update.mockResolvedValue({});
});
- it('should write a grant confined to a group the user belongs to', async () => {
- const permissions: Permissions = [{ action: 'read', groupId: 'group-1', subject: 'Subject' }];
- await usersService.updatePermissions('user-1', permissions);
- expect(userModel.update.mock.lastCall?.[0]).toMatchObject({
- data: { additionalPermissions: permissions },
- where: { id: 'user-1' }
+ describe('custom grants', () => {
+ it('should write a grant confined to a group the user belongs to', async () => {
+ const permissions: Permissions = [{ action: 'read', groupId: 'group-1', subject: 'Subject' }];
+ await usersService.updatePermissions('user-1', { kind: 'custom', permissions });
+ expect(userModel.update.mock.lastCall?.[0]).toMatchObject({
+ data: { additionalPermissions: permissions },
+ where: { id: 'user-1' }
+ });
});
- });
- it('should reject a grant confined to a group the user does not belong to, before writing anything', async () => {
- const permissions: Permissions = [{ action: 'read', groupId: 'group-2', subject: 'Subject' }];
- await expect(usersService.updatePermissions('user-1', permissions)).rejects.toThrow(BadRequestException);
- expect(userModel.update).not.toHaveBeenCalled();
+ it('should remove the permission set in the same write, so the user never holds both', async () => {
+ await usersService.updatePermissions('user-1', { kind: 'custom', permissions: [] });
+ expect(userModel.update.mock.lastCall?.[0].data.permissionSet).toEqual({ disconnect: true });
+ });
+
+ it('should reject a grant confined to a group the user does not belong to, before writing anything', async () => {
+ const permissions: Permissions = [{ action: 'read', groupId: 'group-2', subject: 'Subject' }];
+ await expect(usersService.updatePermissions('user-1', { kind: 'custom', permissions })).rejects.toThrow(
+ BadRequestException
+ );
+ expect(userModel.update).not.toHaveBeenCalled();
+ });
+
+ it("should accept an unscoped grant whatever the user's groups", async () => {
+ const permissions: Permissions = [{ action: 'create', groupId: null, subject: 'Instrument' }];
+ await usersService.updatePermissions('user-1', { kind: 'custom', permissions });
+ expect(userModel.update.mock.lastCall?.[0]).toMatchObject({ data: { additionalPermissions: permissions } });
+ });
});
- it("should accept an unscoped grant whatever the user's groups", async () => {
- const permissions: Permissions = [{ action: 'create', groupId: null, subject: 'Instrument' }];
- await usersService.updatePermissions('user-1', permissions);
- expect(userModel.update.mock.lastCall?.[0]).toMatchObject({ data: { additionalPermissions: permissions } });
+ describe('a permission set', () => {
+ const data = { kind: 'permissionSet', permissionSetId: 'set-1' } as const;
+
+ it('should link the set and empty the custom grants in the same write, so the user never holds both', async () => {
+ await usersService.updatePermissions('user-1', data);
+ expect(userModel.update.mock.lastCall?.[0]).toMatchObject({
+ data: { additionalPermissions: [], permissionSet: { connect: { id: 'set-1' } } },
+ where: { id: 'user-1' }
+ });
+ });
+
+ it('should look up the set with the caller ability', async () => {
+ await usersService.updatePermissions('user-1', data, { ability: admin.ability });
+ expect(permissionSetsService.findById).toHaveBeenCalledWith('set-1', { ability: admin.ability });
+ });
+
+ it('should propagate a set lookup that finds nothing, before writing anything', async () => {
+ permissionSetsService.findById.mockRejectedValue(new NotFoundException());
+ await expect(usersService.updatePermissions('user-1', data)).rejects.toThrow(NotFoundException);
+ expect(userModel.update).not.toHaveBeenCalled();
+ });
+
+ it('should refuse a set for an administrator, who already holds every permission', async () => {
+ userModel.findFirst.mockResolvedValue({ basePermissionLevel: 'ADMIN', groupIds: [], id: 'user-1' });
+ await expect(usersService.updatePermissions('user-1', data)).rejects.toThrow(BadRequestException);
+ expect(userModel.update).not.toHaveBeenCalled();
+ });
});
it('should throw when the user cannot be found, rather than creating one', async () => {
userModel.findFirst.mockResolvedValue(null);
- await expect(usersService.updatePermissions('user-1', [])).rejects.toThrow(NotFoundException);
+ await expect(usersService.updatePermissions('user-1', { kind: 'custom', permissions: [] })).rejects.toThrow(
+ NotFoundException
+ );
expect(userModel.update).not.toHaveBeenCalled();
});
- it('should scope the write to the caller ability', async () => {
+ it.each<$UpdateUserPermissionsData>([
+ { kind: 'custom', permissions: [] },
+ { kind: 'permissionSet', permissionSetId: 'set-1' }
+ ])('should scope a $kind write to the caller ability', async (data) => {
const ability = createAppAbility([
{ action: 'manage', conditions: { groupIds: { hasSome: ['group-1'] } }, subject: 'User' }
]);
- await usersService.updatePermissions('user-1', [], { ability });
+ await usersService.updatePermissions('user-1', data, { ability });
expect(userModel.update.mock.lastCall?.[0]).toMatchObject({
where: { AND: [accessibleQuery(ability, 'update', 'User')], id: 'user-1' }
});
diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts
index 8f91529a8..0e0287b40 100644
--- a/apps/api/src/users/users.controller.ts
+++ b/apps/api/src/users/users.controller.ts
@@ -104,10 +104,10 @@ export class UsersController {
@RouteAccess(ADMIN_ONLY)
updatePermissions(
@Param('id') id: string,
- @Body() { permissions }: $UpdateUserPermissionsData,
+ @Body() data: $UpdateUserPermissionsData,
@CurrentUser('ability') ability: AppAbility
) {
- return this.usersService.updatePermissions(id, permissions, { ability });
+ return this.usersService.updatePermissions(id, data, { ability });
}
@ApiOperation({ summary: 'Self Update User' })
diff --git a/apps/api/src/users/users.module.ts b/apps/api/src/users/users.module.ts
index ca53f69da..2fbf8195d 100644
--- a/apps/api/src/users/users.module.ts
+++ b/apps/api/src/users/users.module.ts
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { GroupsModule } from '@/groups/groups.module';
import { MailModule } from '@/mail/mail.module';
+import { PermissionSetsModule } from '@/permission-sets/permission-sets.module';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@@ -9,7 +10,7 @@ import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
exports: [UsersService],
- imports: [GroupsModule, MailModule],
+ imports: [GroupsModule, MailModule, PermissionSetsModule],
providers: [UsersService]
})
export class UsersModule {}
diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts
index 735e6368a..ff696c8ea 100644
--- a/apps/api/src/users/users.service.ts
+++ b/apps/api/src/users/users.service.ts
@@ -9,8 +9,12 @@ import {
Logger,
NotFoundException
} from '@nestjs/common';
-import type { Permissions } from '@opendatacapture/schemas/core';
-import { $CreateUserData, $SelfUpdateUserData, $UpdateUserData } from '@opendatacapture/schemas/user';
+import {
+ $CreateUserData,
+ $SelfUpdateUserData,
+ $UpdateUserData,
+ $UpdateUserPermissionsData
+} from '@opendatacapture/schemas/user';
import type { PasswordErrorCode } from '@opendatacapture/schemas/user';
import { pwnedPassword } from 'hibp';
@@ -18,6 +22,7 @@ import { AuditLogger } from '@/audit/audit.logger';
import { accessibleQuery } from '@/auth/ability.utils';
import type { EntityOperationOptions } from '@/core/types';
import { GroupsService } from '@/groups/groups.service';
+import { PermissionSetsService } from '@/permission-sets/permission-sets.service';
@Injectable()
export class UsersService {
@@ -27,7 +32,8 @@ export class UsersService {
@InjectModel('User') private readonly userModel: Model<'User'>,
private readonly auditLogger: AuditLogger,
private readonly cryptoService: CryptoService,
- private readonly groupsService: GroupsService
+ private readonly groupsService: GroupsService,
+ private readonly permissionSetsService: PermissionSetsService
) {}
async archiveById(id: string, currentUser: RequestUser) {
@@ -72,7 +78,7 @@ export class UsersService {
});
}
- /** Adds a new user to the database with default permissions, verifying the provided groups exist */
+ /** Adds a new user to the database with default permissions, verifying the provided groups and permission set exist */
async create(
{
basePermissionLevel,
@@ -84,6 +90,7 @@ export class UsersService {
lastName,
mustResetPassword,
password,
+ permissionSetId,
phoneNumber,
sex,
username
@@ -100,6 +107,9 @@ export class UsersService {
for (const id of groupIds) {
await this.groupsService.findById(id, options);
}
+ if (permissionSetId !== undefined) {
+ await this.permissionSetsService.findById(permissionSetId, options);
+ }
const hashedPassword = await this.cryptoService.hashPassword(password);
@@ -117,6 +127,7 @@ export class UsersService {
hashedPassword,
lastName,
mustResetPassword,
+ permissionSet: permissionSetId === undefined ? undefined : { connect: { id: permissionSetId } },
phoneNumber,
sex,
username
@@ -156,7 +167,7 @@ export class UsersService {
{ ability, includeHashedPassword }: EntityOperationOptions & { includeHashedPassword?: boolean } = {}
) {
const user = await this.userModel.findFirst({
- include: { groups: true },
+ include: { groups: true, permissionSet: true },
omit: {
hashedPassword: !includeHashedPassword
},
@@ -208,7 +219,10 @@ export class UsersService {
groups: {
set: groupIds?.map((id) => ({ id }))
},
- hashedPassword
+ hashedPassword,
+ // An administrator already holds every permission, so a set they kept would only inflate its
+ // holder count. Relation syntax because `groups` above is one, and Prisma will not mix the two.
+ permissionSet: data.basePermissionLevel === 'ADMIN' ? { disconnect: true } : undefined
},
omit: {
hashedPassword: true
@@ -218,12 +232,28 @@ export class UsersService {
}
/**
- * Replaces the user's additional permissions. A grant confined to a group is accepted only when the
- * user belongs to that group, so what the web client offers and what this accepts are one rule.
+ * Replaces where the user's permissions beyond their base level come from: a permission set, or
+ * custom grants. Each write empties the other, which is what keeps a user from holding both. A
+ * custom grant confined to a group is accepted only when the user belongs to that group, so what
+ * the web client offers and what this accepts are one rule.
*/
- async updatePermissions(id: string, permissions: Permissions, { ability }: EntityOperationOptions = {}) {
+ async updatePermissions(id: string, data: $UpdateUserPermissionsData, { ability }: EntityOperationOptions = {}) {
const user = await this.findById(id, { ability });
- const foreignGroupIds = permissions
+ const where = { AND: [accessibleQuery(ability, 'update', 'User')], id };
+ if (data.kind === 'permissionSet') {
+ if (user.basePermissionLevel === 'ADMIN') {
+ throw new BadRequestException('Administrators hold every permission, so they cannot hold a permission set');
+ }
+ await this.permissionSetsService.findById(data.permissionSetId, { ability });
+ return this.userModel.update({
+ data: { additionalPermissions: [], permissionSet: { connect: { id: data.permissionSetId } } },
+ omit: {
+ hashedPassword: true
+ },
+ where
+ });
+ }
+ const foreignGroupIds = data.permissions
.map(({ groupId }) => groupId)
.filter((groupId): groupId is string => groupId !== null && !user.groupIds.includes(groupId));
if (foreignGroupIds.length) {
@@ -232,11 +262,11 @@ export class UsersService {
);
}
return this.userModel.update({
- data: { additionalPermissions: permissions },
+ data: { additionalPermissions: data.permissions, permissionSet: { disconnect: true } },
omit: {
hashedPassword: true
},
- where: { AND: [accessibleQuery(ability, 'update', 'User')], id }
+ where
});
}
diff --git a/apps/api/test/suites/02-user-permissions.suite.ts b/apps/api/test/suites/02-user-permissions.suite.ts
index 2f55d8a9a..e2c6167f6 100644
--- a/apps/api/test/suites/02-user-permissions.suite.ts
+++ b/apps/api/test/suites/02-user-permissions.suite.ts
@@ -79,7 +79,7 @@ export default defineSuite('user permissions', function () {
const response = await this.app.inject({
headers: adminHeaders,
method: 'PUT',
- payload: { permissions: [{ action: 'read', groupId: otherGroup.id, subject: 'Subject' }] },
+ payload: { kind: 'custom', permissions: [{ action: 'read', groupId: otherGroup.id, subject: 'Subject' }] },
url: `/v1/users/${user.id}/permissions`
});
expect(response.statusCode).toBe(400);
@@ -89,7 +89,7 @@ export default defineSuite('user permissions', function () {
const response = await this.app.inject({
headers: adminHeaders,
method: 'PUT',
- payload: { permissions: [{ action: 'read', groupId: group.id, subject: 'Subject' }] },
+ payload: { kind: 'custom', permissions: [{ action: 'read', groupId: group.id, subject: 'Subject' }] },
url: `/v1/users/${user.id}/permissions`
});
expect(response.statusCode).toBe(200);
diff --git a/apps/api/test/suites/03-record-file-deletion.suite.ts b/apps/api/test/suites/03-record-file-deletion.suite.ts
index 1a6cda61f..0c4f2ee66 100644
--- a/apps/api/test/suites/03-record-file-deletion.suite.ts
+++ b/apps/api/test/suites/03-record-file-deletion.suite.ts
@@ -91,6 +91,7 @@ export default defineSuite('record file deletion', function () {
headers: adminHeaders,
method: 'PUT',
payload: {
+ kind: 'custom',
permissions: ['InstrumentRecord', 'Session', 'Subject'].map((subject) => ({
action: 'delete',
groupId,
diff --git a/apps/api/test/suites/04-permission-sets.suite.ts b/apps/api/test/suites/04-permission-sets.suite.ts
new file mode 100644
index 000000000..979b4c118
--- /dev/null
+++ b/apps/api/test/suites/04-permission-sets.suite.ts
@@ -0,0 +1,282 @@
+import { randomUUID } from 'node:crypto';
+
+import type { Group } from '@opendatacapture/schemas/group';
+import type { PermissionSet, PermissionSetSummary } from '@opendatacapture/schemas/permission-set';
+import type { User } from '@opendatacapture/schemas/user';
+import { beforeAll, describe, expect, it } from 'vitest';
+
+import { defineSuite } from '../helpers';
+
+const PASSWORD = 'DataCapture2025_Test';
+
+type SignedRule = { action: string; conditions?: unknown; subject: string };
+
+/**
+ * Runs after `02-user-permissions`, whose setup created the administrator. Everything here is proven
+ * against the real database: holder counts, the `SetNull` that deleting a set relies on, and the
+ * rules a holder's token ends up carrying.
+ */
+export default defineSuite('permission sets', function () {
+ let adminHeaders: { authorization: string };
+ let group: Group;
+
+ const login = async (username: string): Promise => {
+ const response = await this.app.inject({
+ method: 'POST',
+ payload: { password: PASSWORD, username },
+ url: '/v1/auth/login'
+ });
+ expect(response.statusCode).toBe(200);
+ return response.json<{ accessToken: string }>().accessToken;
+ };
+
+ const signedRules = (accessToken: string): SignedRule[] => {
+ const payload = Buffer.from(accessToken.split('.')[1]!, 'base64url').toString();
+ return (JSON.parse(payload) as { permissions: SignedRule[] }).permissions;
+ };
+
+ const createPermissionSet = async (): Promise => {
+ const response = await this.app.inject({
+ headers: adminHeaders,
+ method: 'POST',
+ payload: {
+ name: `Set ${randomUUID()}`,
+ rules: [
+ { action: 'read', scope: 'HOLDER_GROUPS', subject: 'InstrumentRecord' },
+ { action: 'read', scope: 'ALL_GROUPS', subject: 'Assignment' }
+ ]
+ },
+ url: '/v1/permission-sets'
+ });
+ expect(response.statusCode).toBe(201);
+ return response.json();
+ };
+
+ const createUser = async (payload: object = {}) => {
+ return this.app.inject({
+ headers: adminHeaders,
+ method: 'POST',
+ payload: {
+ basePermissionLevel: 'STANDARD',
+ firstName: 'Set',
+ groupIds: [group.id],
+ lastName: 'Holder',
+ password: PASSWORD,
+ username: `holder-${randomUUID()}`,
+ ...payload
+ },
+ url: '/v1/users'
+ });
+ };
+
+ const findPermissionSet = async (id: string): Promise => {
+ const response = await this.app.inject({ headers: adminHeaders, method: 'GET', url: `/v1/permission-sets/${id}` });
+ expect(response.statusCode).toBe(200);
+ return response.json();
+ };
+
+ const findUser = async (id: string): Promise => {
+ const response = await this.app.inject({ headers: adminHeaders, method: 'GET', url: `/v1/users/${id}` });
+ expect(response.statusCode).toBe(200);
+ return response.json();
+ };
+
+ const setPermissions = (id: string, payload: object) => {
+ return this.app.inject({ headers: adminHeaders, method: 'PUT', payload, url: `/v1/users/${id}/permissions` });
+ };
+
+ beforeAll(async () => {
+ adminHeaders = { authorization: `Bearer ${await login('admin')}` };
+ const response = await this.app.inject({
+ headers: adminHeaders,
+ method: 'POST',
+ payload: { name: 'Permission Set Group', type: 'RESEARCH' },
+ url: '/v1/groups'
+ });
+ expect(response.statusCode).toBe(201);
+ group = response.json();
+ });
+
+ describe('POST /v1/permission-sets', () => {
+ it('should refuse a name another set already has', async () => {
+ const { name } = await createPermissionSet();
+ const response = await this.app.inject({
+ headers: adminHeaders,
+ method: 'POST',
+ payload: { name, rules: [{ action: 'read', scope: 'ALL_GROUPS', subject: 'Subject' }] },
+ url: '/v1/permission-sets'
+ });
+ expect(response.statusCode).toBe(409);
+ });
+
+ it('should refuse a set granting manage all, which would make every holder an administrator', async () => {
+ const response = await this.app.inject({
+ headers: adminHeaders,
+ method: 'POST',
+ payload: { name: `Set ${randomUUID()}`, rules: [{ action: 'manage', scope: 'ALL_GROUPS', subject: 'all' }] },
+ url: '/v1/permission-sets'
+ });
+ expect(response.statusCode).toBe(400);
+ });
+
+ it('should refuse a user below administrator, even one whose set reaches every other resource', async () => {
+ const response = await this.app.inject({
+ headers: adminHeaders,
+ method: 'POST',
+ payload: {
+ name: `Set ${randomUUID()}`,
+ rules: (['create', 'delete', 'read', 'update'] as const).map((action) => ({
+ action,
+ scope: 'ALL_GROUPS',
+ subject: 'all'
+ }))
+ },
+ url: '/v1/permission-sets'
+ });
+ expect(response.statusCode).toBe(201);
+ const holder = await createUser({ permissionSetId: response.json().id });
+ expect(holder.statusCode).toBe(201);
+ const headers = { authorization: `Bearer ${await login(holder.json().username)}` };
+ const listing = await this.app.inject({ headers, method: 'GET', url: '/v1/permission-sets' });
+ expect(listing.statusCode).toBe(403);
+ });
+ });
+
+ describe('assigning a set', () => {
+ it('should link a set when the user is created, and count them as a holder', async () => {
+ const permissionSet = await createPermissionSet();
+ const response = await createUser({ permissionSetId: permissionSet.id });
+ expect(response.statusCode).toBe(201);
+ expect(response.json().permissionSetId).toBe(permissionSet.id);
+ expect((await findPermissionSet(permissionSet.id)).holderCount).toBe(1);
+ });
+
+ it('should refuse a set for an administrator being created', async () => {
+ const permissionSet = await createPermissionSet();
+ const response = await createUser({ basePermissionLevel: 'ADMIN', permissionSetId: permissionSet.id });
+ expect(response.statusCode).toBe(400);
+ });
+
+ it('should refuse a set that does not exist', async () => {
+ const response = await createUser({ permissionSetId: '000000000000000000000000' });
+ expect(response.statusCode).toBe(404);
+ });
+
+ it("should sign the set's rules into the holder's token, confined to the holder's groups where the set says so", async () => {
+ const permissionSet = await createPermissionSet();
+ const user = (await createUser()).json();
+ const response = await setPermissions(user.id, { kind: 'permissionSet', permissionSetId: permissionSet.id });
+ expect(response.statusCode).toBe(200);
+ const rules = signedRules(await login(user.username));
+ expect(rules).toContainEqual({
+ action: 'read',
+ conditions: { groupId: { in: [group.id] } },
+ subject: 'InstrumentRecord'
+ });
+ expect(rules).toContainEqual({ action: 'read', subject: 'Assignment' });
+ });
+
+ it('should switch between a set and custom grants, each write emptying the other', async () => {
+ const permissionSet = await createPermissionSet();
+ const user = (await createUser()).json();
+ const custom = { kind: 'custom', permissions: [{ action: 'read', groupId: null, subject: 'Session' }] };
+
+ expect((await setPermissions(user.id, custom)).statusCode).toBe(200);
+ expect(
+ (await setPermissions(user.id, { kind: 'permissionSet', permissionSetId: permissionSet.id })).statusCode
+ ).toBe(200);
+ expect(await findUser(user.id)).toMatchObject({ additionalPermissions: [], permissionSetId: permissionSet.id });
+
+ expect((await setPermissions(user.id, custom)).statusCode).toBe(200);
+ expect(await findUser(user.id)).toMatchObject({
+ additionalPermissions: custom.permissions,
+ permissionSetId: null
+ });
+ expect((await findPermissionSet(permissionSet.id)).holderCount).toBe(0);
+ });
+
+ it('should refuse a set for an existing administrator', async () => {
+ const permissionSet = await createPermissionSet();
+ const admin = (await createUser({ basePermissionLevel: 'ADMIN', groupIds: [] })).json();
+ const response = await setPermissions(admin.id, { kind: 'permissionSet', permissionSetId: permissionSet.id });
+ expect(response.statusCode).toBe(400);
+ });
+
+ it('should remove the set from a user promoted to administrator', async () => {
+ const permissionSet = await createPermissionSet();
+ const user = (await createUser({ permissionSetId: permissionSet.id })).json();
+ const response = await this.app.inject({
+ headers: adminHeaders,
+ method: 'PATCH',
+ payload: { basePermissionLevel: 'ADMIN' },
+ url: `/v1/users/${user.id}`
+ });
+ expect(response.statusCode).toBe(200);
+ expect((await findUser(user.id)).permissionSetId).toBeNull();
+ });
+
+ it('should promote a user who never held a set', async () => {
+ const user = (await createUser()).json();
+ const response = await this.app.inject({
+ headers: adminHeaders,
+ method: 'PATCH',
+ payload: { basePermissionLevel: 'ADMIN' },
+ url: `/v1/users/${user.id}`
+ });
+ expect(response.statusCode).toBe(200);
+ });
+ });
+
+ describe('PATCH /v1/permission-sets/:id', () => {
+ it("should change what a holder's next token carries", async () => {
+ const permissionSet = await createPermissionSet();
+ const user = (await createUser({ permissionSetId: permissionSet.id })).json();
+ const response = await this.app.inject({
+ headers: adminHeaders,
+ method: 'PATCH',
+ payload: { rules: [{ action: 'read', scope: 'ALL_GROUPS', subject: 'Session' }] },
+ url: `/v1/permission-sets/${permissionSet.id}`
+ });
+ expect(response.statusCode).toBe(200);
+ const rules = signedRules(await login(user.username));
+ expect(rules).toContainEqual({ action: 'read', subject: 'Session' });
+ expect(rules).not.toContainEqual(expect.objectContaining({ subject: 'Assignment' }));
+ });
+ });
+
+ describe('DELETE /v1/permission-sets/:id', () => {
+ it('should remove the set from every holder, leaving them their base level alone', async () => {
+ const permissionSet = await createPermissionSet();
+ const holders = [
+ (await createUser({ permissionSetId: permissionSet.id })).json(),
+ (await createUser({ permissionSetId: permissionSet.id })).json()
+ ];
+ expect((await findPermissionSet(permissionSet.id)).holderCount).toBe(2);
+
+ const response = await this.app.inject({
+ headers: adminHeaders,
+ method: 'DELETE',
+ url: `/v1/permission-sets/${permissionSet.id}`
+ });
+ expect(response.statusCode).toBe(200);
+
+ for (const holder of holders) {
+ expect((await findUser(holder.id)).permissionSetId).toBeNull();
+ expect(signedRules(await login(holder.username))).not.toContainEqual(
+ expect.objectContaining({ subject: 'Assignment' })
+ );
+ }
+ const listing = await this.app.inject({ headers: adminHeaders, method: 'GET', url: '/v1/permission-sets' });
+ expect(listing.json().map(({ id }) => id)).not.toContain(permissionSet.id);
+ });
+
+ it('should answer a set that does not exist with not found', async () => {
+ const response = await this.app.inject({
+ headers: adminHeaders,
+ method: 'DELETE',
+ url: '/v1/permission-sets/000000000000000000000000'
+ });
+ expect(response.statusCode).toBe(404);
+ });
+ });
+});
diff --git a/packages/schemas/AGENTS.md b/packages/schemas/AGENTS.md
index 3cbb753f0..a19950165 100644
--- a/packages/schemas/AGENTS.md
+++ b/packages/schemas/AGENTS.md
@@ -64,9 +64,15 @@ body is parsed against it; `AuthPayload` and `TokenPayload` are plain types beca
parses them. Do not add a schema for a shape that only crosses a typed boundary.
Variants are composed with `.extend()` / `.pick()` / `.omit()` / `.partial()` off an existing shape,
-never rewritten — `$UpdateUserData = $CreateUserData.partial().extend({...})`. Rewriting is how the
+never rewritten — `$UpdateUserData = $BaseUserData.partial().extend({...})`. Rewriting is how the
two drift.
+**Derive only from an object without a `.check`.** The vendored zod silently drops a `.check` when
+you `.extend()`, `.pick()`, `.omit()` or `.partial()` the object carrying it, and newer zod releases
+throw on some of these instead. Where a schema needs a rule relating its fields, keep the unchecked
+shape as its own schema and derive every variant from that — `$BaseUserData` is the shape
+`$CreateUserData` checks and `$UpdateUserData` makes partial.
+
## src/instrument
The one barrel in the package (`instrument.ts` re-exports the six sibling files), and the only place
diff --git a/packages/schemas/package.json b/packages/schemas/package.json
index a619e8c5e..a063d1dbf 100644
--- a/packages/schemas/package.json
+++ b/packages/schemas/package.json
@@ -15,6 +15,7 @@
"./instrument-records": "./src/instrument-records/instrument-records.ts",
"./instrument-repo": "./src/instrument-repo/instrument-repo.ts",
"./mail": "./src/mail/mail.ts",
+ "./permission-set": "./src/permission-set/permission-set.ts",
"./session": "./src/session/session.ts",
"./setup": "./src/setup/setup.ts",
"./storage": "./src/storage/storage.ts",
diff --git a/packages/schemas/src/permission-set/__tests__/permission-set.test.ts b/packages/schemas/src/permission-set/__tests__/permission-set.test.ts
new file mode 100644
index 000000000..c563040ad
--- /dev/null
+++ b/packages/schemas/src/permission-set/__tests__/permission-set.test.ts
@@ -0,0 +1,125 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ $CreatePermissionSetData,
+ $PermissionSet,
+ $PermissionSetRule,
+ $PermissionSetSummary,
+ $UpdatePermissionSetData,
+ isPermissionSetGrantable
+} from '../permission-set.js';
+
+const STORED_SET = {
+ createdAt: new Date(),
+ id: 'set-1',
+ name: 'Research Assistant',
+ rules: [{ action: 'read', scope: 'HOLDER_GROUPS', subject: 'Subject' }],
+ updatedAt: new Date()
+};
+
+describe('$PermissionSetRule', () => {
+ it('should accept a rule confined to the holder groups on a resource that belongs to a group', () => {
+ expect($PermissionSetRule.safeParse({ action: 'read', scope: 'HOLDER_GROUPS', subject: 'Subject' }).success).toBe(
+ true
+ );
+ });
+
+ it('should reject a rule confined to the holder groups on a resource no group owns', () => {
+ const result = $PermissionSetRule.safeParse({ action: 'read', scope: 'HOLDER_GROUPS', subject: 'Instrument' });
+ expect(result.error?.issues[0]?.path).toEqual(['scope']);
+ });
+
+ it('should accept a rule on a resource no group owns when it applies to every group', () => {
+ expect($PermissionSetRule.safeParse({ action: 'read', scope: 'ALL_GROUPS', subject: 'Instrument' }).success).toBe(
+ true
+ );
+ });
+});
+
+describe('$PermissionSet', () => {
+ it('should accept a stored rule a set may no longer contain, so a set saved before still parses', () => {
+ const rules = [{ action: 'manage', scope: 'ALL_GROUPS', subject: 'all' }];
+ expect($PermissionSet.safeParse({ ...STORED_SET, rules }).success).toBe(true);
+ });
+});
+
+describe('$PermissionSetSummary', () => {
+ it('should reject a negative holder count', () => {
+ expect($PermissionSetSummary.safeParse({ ...STORED_SET, holderCount: -1 }).success).toBe(false);
+ });
+
+ it('should accept a set with no holders', () => {
+ expect($PermissionSetSummary.safeParse({ ...STORED_SET, holderCount: 0 }).success).toBe(true);
+ });
+});
+
+describe('isPermissionSetGrantable', () => {
+ it('should refuse manage all, which would make every holder an administrator', () => {
+ expect(isPermissionSetGrantable({ action: 'manage', subject: 'all' })).toBe(false);
+ });
+
+ it('should refuse a user write, which only an administrator can make', () => {
+ expect(isPermissionSetGrantable({ action: 'update', subject: 'User' })).toBe(false);
+ });
+
+ it('should allow every other action on every resource, as a per-user grant would', () => {
+ expect(isPermissionSetGrantable({ action: 'read', subject: 'all' })).toBe(true);
+ expect(isPermissionSetGrantable({ action: 'manage', subject: 'Subject' })).toBe(true);
+ });
+});
+
+describe('$CreatePermissionSetData', () => {
+ const data = { name: 'Research Assistant', rules: [{ action: 'read', scope: 'HOLDER_GROUPS', subject: 'Subject' }] };
+
+ it('should accept a named set with one rule', () => {
+ expect($CreatePermissionSetData.safeParse(data).success).toBe(true);
+ });
+
+ it('should reject a set with no rules, which would grant nothing', () => {
+ expect($CreatePermissionSetData.safeParse({ ...data, rules: [] }).success).toBe(false);
+ });
+
+ it('should reject a blank name, which would leave the set unidentifiable in the list', () => {
+ expect($CreatePermissionSetData.safeParse({ ...data, name: '' }).success).toBe(false);
+ });
+
+ it('should reject manage all, naming the offending rule', () => {
+ const rules = [...data.rules, { action: 'manage', scope: 'ALL_GROUPS', subject: 'all' }];
+ const result = $CreatePermissionSetData.safeParse({ ...data, rules });
+ expect(result.error?.issues).toMatchObject([
+ { message: 'A permission set cannot make its holders administrators', path: ['rules', 1] }
+ ]);
+ });
+
+ it('should reject a user write, naming the offending rule', () => {
+ const rules = [{ action: 'update', scope: 'HOLDER_GROUPS', subject: 'User' }];
+ const result = $CreatePermissionSetData.safeParse({ ...data, rules });
+ expect(result.error?.issues).toMatchObject([
+ {
+ message: 'Only an administrator can create, modify or delete users, so this permission would do nothing',
+ path: ['rules', 0]
+ }
+ ]);
+ });
+
+ it('should reject a rule repeated in the set, naming the repeat', () => {
+ const result = $CreatePermissionSetData.safeParse({ ...data, rules: [...data.rules, ...data.rules] });
+ expect(result.error?.issues).toMatchObject([{ path: ['rules', 1] }]);
+ });
+
+ it('should accept the same rule at two scopes, since they grant different rows', () => {
+ const rules = [...data.rules, { action: 'read', scope: 'ALL_GROUPS', subject: 'Subject' }];
+ expect($CreatePermissionSetData.safeParse({ ...data, rules }).success).toBe(true);
+ });
+});
+
+describe('$UpdatePermissionSetData', () => {
+ it('should accept a rename alone, leaving the rules as they are', () => {
+ expect($UpdatePermissionSetData.safeParse({ name: 'Renamed' }).success).toBe(true);
+ });
+
+ it('should apply the same rule check as a create when the rules are replaced', () => {
+ const rules = [{ action: 'manage', scope: 'ALL_GROUPS', subject: 'all' }];
+ expect($UpdatePermissionSetData.safeParse({ rules }).success).toBe(false);
+ });
+});
diff --git a/packages/schemas/src/permission-set/permission-set.ts b/packages/schemas/src/permission-set/permission-set.ts
new file mode 100644
index 000000000..04233f79e
--- /dev/null
+++ b/packages/schemas/src/permission-set/permission-set.ts
@@ -0,0 +1,107 @@
+import { z } from 'zod/v4';
+
+import {
+ $AppAction,
+ $AppSubjectName,
+ $BaseModel,
+ isGrantablePermission,
+ isGroupScopableSubject
+} from '../core/core.js';
+
+import type { UserPermission } from '../core/core.js';
+
+/**
+ * Where a rule in a permission set applies, relative to whoever holds the set: the groups the holder
+ * belongs to when they sign in, or every group. A set names no group itself, so one set can serve
+ * users in different groups.
+ */
+const $PermissionSetScope = z.enum(['ALL_GROUPS', 'HOLDER_GROUPS']);
+
+/**
+ * One rule of a permission set, as stored. This is the read model, so like `$UserPermission` it does
+ * not apply {@link isPermissionSetGrantable}: a set stored before a rule was tightened must still parse.
+ */
+const $PermissionSetRule = z
+ .object({
+ action: $AppAction,
+ scope: $PermissionSetScope,
+ subject: $AppSubjectName
+ })
+ .check((ctx) => {
+ if (ctx.value.scope === 'HOLDER_GROUPS' && !isGroupScopableSubject(ctx.value.subject)) {
+ ctx.issues.push({
+ code: 'custom',
+ input: ctx.value.scope,
+ message: 'This resource cannot be scoped to a group',
+ path: ['scope']
+ });
+ }
+ });
+
+/**
+ * Whether a permission set may contain a rule. Everything a per-user grant may name, except
+ * `manage all`: that makes its holder an administrator, so a single edit to the set would promote
+ * every holder at once. The `ADMIN` permission level exists for that.
+ */
+const isPermissionSetGrantable = ({ action, subject }: Pick): boolean => {
+ return isGrantablePermission({ action, subject }) && !(action === 'manage' && subject === 'all');
+};
+
+/** The rules a write may store: at least one, none repeated, and each one a set may contain. */
+const $GrantablePermissionSetRules = z
+ .array($PermissionSetRule)
+ .min(1)
+ .check((ctx) => {
+ const seen = new Set();
+ ctx.value.forEach((rule, index) => {
+ if (!isPermissionSetGrantable(rule)) {
+ ctx.issues.push({
+ code: 'custom',
+ input: rule,
+ message: isGrantablePermission(rule)
+ ? 'A permission set cannot make its holders administrators'
+ : 'Only an administrator can create, modify or delete users, so this permission would do nothing',
+ path: [index]
+ });
+ }
+ const key = [rule.action, rule.subject, rule.scope].join(' ');
+ if (seen.has(key)) {
+ ctx.issues.push({ code: 'custom', input: rule, message: 'This rule is already in the set', path: [index] });
+ }
+ seen.add(key);
+ });
+ });
+
+const $PermissionSet = $BaseModel.extend({
+ name: z.string().min(1),
+ rules: $PermissionSetRule.array()
+});
+
+/** What the permission set routes return: the set, and how many users hold it. */
+const $PermissionSetSummary = $PermissionSet.extend({
+ holderCount: z.number().int().nonnegative()
+});
+
+const $CreatePermissionSetData = z.object({
+ name: z.string().min(1),
+ rules: $GrantablePermissionSetRules
+});
+
+const $UpdatePermissionSetData = $CreatePermissionSetData.partial();
+
+export type $CreatePermissionSetData = z.infer;
+export type PermissionSet = z.infer;
+export type PermissionSetRule = z.infer;
+export type PermissionSetScope = z.infer;
+export type PermissionSetSummary = z.infer;
+export type $UpdatePermissionSetData = z.infer;
+
+export {
+ $CreatePermissionSetData,
+ $PermissionSet,
+ $PermissionSetRule,
+ $PermissionSetScope,
+ $PermissionSetSummary,
+ $UpdatePermissionSetData,
+ isPermissionSetGrantable
+};
diff --git a/packages/schemas/src/setup/__tests__/setup.test.ts b/packages/schemas/src/setup/__tests__/setup.test.ts
index 62de08d9d..180d76249 100644
--- a/packages/schemas/src/setup/__tests__/setup.test.ts
+++ b/packages/schemas/src/setup/__tests__/setup.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { $BrandingConfig, $UpdateSetupStateData } from '../setup.js';
+import { $BrandingConfig, $InitAppOptions, $UpdateSetupStateData } from '../setup.js';
describe('$BrandingConfig', () => {
describe('customLogoSrc', () => {
@@ -107,3 +107,14 @@ describe('$BrandingConfig', () => {
});
});
});
+
+describe('$InitAppOptions', () => {
+ it('should drop a permission set sent for the first administrator, who holds every permission already', () => {
+ const result = $InitAppOptions.safeParse({
+ admin: { firstName: 'Ada', lastName: 'Admin', password: 'password', permissionSetId: 'set-1', username: 'admin' },
+ enableExperimentalFeatures: false,
+ initDemo: false
+ });
+ expect(result.data?.admin).not.toHaveProperty('permissionSetId');
+ });
+});
diff --git a/packages/schemas/src/setup/setup.ts b/packages/schemas/src/setup/setup.ts
index 1a4a7e829..89968d155 100644
--- a/packages/schemas/src/setup/setup.ts
+++ b/packages/schemas/src/setup/setup.ts
@@ -1,7 +1,7 @@
import { z } from 'zod/v4';
import { $ActiveLanguages } from '../core/core.js';
-import { $CreateUserData } from '../user/user.js';
+import { $BaseUserData } from '../user/user.js';
import type { Language } from '../core/core.js';
@@ -229,7 +229,7 @@ const $UpdateSetupStateData = z.object({
tableColorMode: $TableColorMode.optional()
});
-const $CreateAdminData = $CreateUserData.omit({
+const $CreateAdminData = $BaseUserData.omit({
basePermissionLevel: true,
groupIds: true
});
diff --git a/packages/schemas/src/user/__tests__/user.test.ts b/packages/schemas/src/user/__tests__/user.test.ts
index 886cb0c6f..23ab88b22 100644
--- a/packages/schemas/src/user/__tests__/user.test.ts
+++ b/packages/schemas/src/user/__tests__/user.test.ts
@@ -18,16 +18,26 @@ describe('$UpdateUserData', () => {
expect(result.success).toBe(true);
expect(result.data).not.toHaveProperty('additionalPermissions');
});
+
+ it('should strip permissionSetId, so a profile update cannot assign a permission set', () => {
+ const result = $UpdateUserData.safeParse({ permissionSetId: 'set-1' });
+ expect(result.success).toBe(true);
+ expect(result.data).not.toHaveProperty('permissionSetId');
+ });
+
+ it('should accept a promotion to administrator, which the create-time permission set rule does not reach', () => {
+ expect($UpdateUserData.safeParse({ basePermissionLevel: 'ADMIN' }).success).toBe(true);
+ });
});
describe('$UpdateUserPermissionsData', () => {
- it('should accept an empty set, which is how every grant is revoked', () => {
- expect($UpdateUserPermissionsData.safeParse({ permissions: [] }).success).toBe(true);
+ it('should accept an empty set of custom grants, which is how every grant is revoked', () => {
+ expect($UpdateUserPermissionsData.safeParse({ kind: 'custom', permissions: [] }).success).toBe(true);
});
it('should reject a grant confined to a group on a resource that cannot be', () => {
const permissions = [{ action: 'read', groupId: 'group-1', subject: 'Instrument' }];
- expect($UpdateUserPermissionsData.safeParse({ permissions }).success).toBe(false);
+ expect($UpdateUserPermissionsData.safeParse({ kind: 'custom', permissions }).success).toBe(false);
});
it('should reject a grant that writes users, naming the offending entry, since it would do nothing', () => {
@@ -35,10 +45,29 @@ describe('$UpdateUserPermissionsData', () => {
{ action: 'read', groupId: null, subject: 'Subject' },
{ action: 'update', groupId: null, subject: 'User' }
];
- const result = $UpdateUserPermissionsData.safeParse({ permissions });
+ const result = $UpdateUserPermissionsData.safeParse({ kind: 'custom', permissions });
expect(result.success).toBe(false);
expect(result.error?.issues[0]?.path).toEqual(['permissions', 1]);
});
+
+ it('should accept a permission set by its id', () => {
+ expect($UpdateUserPermissionsData.safeParse({ kind: 'permissionSet', permissionSetId: 'set-1' }).success).toBe(
+ true
+ );
+ });
+
+ it('should reject a body without a kind, the shape every write took before permission sets', () => {
+ expect($UpdateUserPermissionsData.safeParse({ permissions: [] }).success).toBe(false);
+ });
+
+ it('should drop custom grants sent beside a permission set, so a user can never hold both', () => {
+ const result = $UpdateUserPermissionsData.safeParse({
+ kind: 'permissionSet',
+ permissions: [{ action: 'read', groupId: null, subject: 'Subject' }],
+ permissionSetId: 'set-1'
+ });
+ expect(result.data).toStrictEqual({ kind: 'permissionSet', permissionSetId: 'set-1' });
+ });
});
describe('$SelfUpdateUserData', () => {
@@ -72,6 +101,15 @@ describe('$CreateUserData', () => {
it('should accept a well-formed phone number', () => {
expect($CreateUserData.safeParse({ ...data, phoneNumber: '(514) 555-1234' }).success).toBe(true);
});
+
+ it('should accept a permission set for a user below administrator', () => {
+ expect($CreateUserData.safeParse({ ...data, permissionSetId: 'set-1' }).success).toBe(true);
+ });
+
+ it('should reject a permission set for an administrator, who already holds every permission', () => {
+ const result = $CreateUserData.safeParse({ ...data, basePermissionLevel: 'ADMIN', permissionSetId: 'set-1' });
+ expect(result.error?.issues[0]?.path).toEqual(['permissionSetId']);
+ });
});
describe('$PhoneNumber', () => {
diff --git a/packages/schemas/src/user/user.ts b/packages/schemas/src/user/user.ts
index d0ba7d38f..50536799d 100644
--- a/packages/schemas/src/user/user.ts
+++ b/packages/schemas/src/user/user.ts
@@ -77,6 +77,11 @@ export const $User = $BaseModel.extend({
* themselves. Nullish because it postdates the users already in the database.
*/
mustResetPassword: z.boolean().nullish(),
+ /**
+ * The permission set this user holds, if any. Never alongside custom `additionalPermissions`:
+ * {@link $UpdateUserPermissionsData} writes the two together, one of them always empty.
+ */
+ permissionSetId: z.string().nullish(),
// Not `$PhoneNumber`: this is the read model, which must still parse numbers stored before the
// digit minimum existed. The rule is enforced on the write schemas below.
phoneNumber: z.string().nullish(),
@@ -88,8 +93,12 @@ export const $User = $BaseModel.extend({
export type CreateUserResponse = z.infer;
export const $CreateUserResponse = $User.extend({ welcomeEmail: $EmailDeliveryResult });
-export type $CreateUserData = z.infer;
-export const $CreateUserData = $User
+/**
+ * The account fields an administrator writes, with none of the rules relating one to another. Every
+ * user write schema derives from this rather than from another write schema, because deriving from
+ * an object drops its `.check` without a word.
+ */
+export const $BaseUserData = $User
.pick({
basePermissionLevel: true,
firstName: true,
@@ -107,33 +116,60 @@ export const $CreateUserData = $User
sex: $Sex.optional()
});
+export type $CreateUserData = z.infer;
+export const $CreateUserData = $BaseUserData
+ .extend({
+ permissionSetId: z.string().optional()
+ })
+ .check((ctx) => {
+ if (ctx.value.basePermissionLevel === 'ADMIN' && ctx.value.permissionSetId !== undefined) {
+ ctx.issues.push({
+ code: 'custom',
+ input: ctx.value.permissionSetId,
+ message: 'Administrators hold every permission, so they cannot hold a permission set',
+ path: ['permissionSetId']
+ });
+ }
+ });
+
/**
* Optional contact details are nullable here, and only here, so an update can clear one.
- * `additionalPermissions` is deliberately absent: it is written only through
- * {@link $UpdateUserPermissionsData}, whose route is gated more tightly than a profile update.
+ * `additionalPermissions` and `permissionSetId` are deliberately absent: they are written only
+ * through {@link $UpdateUserPermissionsData}, whose route is gated more tightly than a profile update.
*/
export type $UpdateUserData = z.infer;
-export const $UpdateUserData = $CreateUserData.partial().extend({
+export const $UpdateUserData = $BaseUserData.partial().extend({
email: z.email().nullish(),
phoneNumber: $PhoneNumber.nullish()
});
-/** The complete set of a user's additional permissions; a write replaces what is stored. */
+/**
+ * Where a user's permissions beyond their base level come from: a permission set, or their own
+ * custom grants. A write replaces both, so a body naming one empties the other and no user can hold
+ * the two at once.
+ */
export type $UpdateUserPermissionsData = z.infer;
-export const $UpdateUserPermissionsData = z.object({
- permissions: $Permissions.check((ctx) => {
- ctx.value.forEach((permission, index) => {
- if (!isGrantablePermission(permission)) {
- ctx.issues.push({
- code: 'custom',
- input: permission,
- message: 'Only an administrator can create, modify or delete users, so this permission would do nothing',
- path: [index]
- });
- }
- });
+export const $UpdateUserPermissionsData = z.discriminatedUnion('kind', [
+ z.object({
+ kind: z.literal('permissionSet'),
+ permissionSetId: z.string()
+ }),
+ z.object({
+ kind: z.literal('custom'),
+ permissions: $Permissions.check((ctx) => {
+ ctx.value.forEach((permission, index) => {
+ if (!isGrantablePermission(permission)) {
+ ctx.issues.push({
+ code: 'custom',
+ input: permission,
+ message: 'Only an administrator can create, modify or delete users, so this permission would do nothing',
+ path: [index]
+ });
+ }
+ });
+ })
})
-});
+]);
export type $SelfUpdateUserData = z.infer;
export const $SelfUpdateUserData = $UpdateUserData
From 2df4f89b93fac71fea8ebe7855914f3f5da51919 Mon Sep 17 00:00:00 2001
From: joshunrau
Date: Tue, 3 Nov 2026 20:54:31 -0500
Subject: [PATCH 2/5] feat(web): manage permission sets and assign them to
users
Adds the admin pages to list, create, edit and delete permission sets, with the holder count
stated before a delete. The user page's permissions card switches between a permission set and
custom permissions, warning when choosing a set discards saved custom grants, and the create-user
form can link a set to any user below administrator.
Co-Authored-By: Claude Opus 5.5
---
.../src/components/CellSelect/CellSelect.tsx | 31 ++
.../CellSelect/__tests__/CellSelect.test.tsx | 45 ++
apps/web/src/components/CellSelect/index.ts | 1 +
.../PermissionSetForm/PermissionSetForm.tsx | 103 ++++
.../__tests__/PermissionSetForm.test.tsx | 116 ++++
.../src/components/PermissionSetForm/index.ts | 1 +
.../PermissionSetRulesEditor.stories.tsx | 62 +++
.../PermissionSetRulesEditor.tsx | 221 ++++++++
.../PermissionSetRulesEditor.test.tsx | 155 ++++++
.../PermissionSetRulesEditor/index.ts | 1 +
.../PermissionSetChoice.tsx | 104 ++++
.../UserPermissionsEditor.tsx | 499 +++++++++---------
.../__tests__/UserPermissionsEditor.test.tsx | 112 +++-
.../useConfirmPermissionSetDeletion.test.tsx | 69 +++
.../useCreatePermissionSetMutation.test.ts | 63 +++
.../__tests__/useCreateUserMutation.test.ts | 6 +
.../useDeletePermissionSetMutation.test.ts | 47 ++
.../src/hooks/__tests__/useNavItems.test.ts | 10 +
.../__tests__/usePermissionLabels.test.ts | 32 ++
.../__tests__/usePermissionSetQuery.test.ts | 67 +++
.../__tests__/usePermissionSetsQuery.test.ts | 69 +++
.../useUpdatePermissionSetMutation.test.ts | 58 ++
.../__tests__/useUpdateUserMutation.test.ts | 26 +-
.../hooks/useConfirmPermissionSetDeletion.ts | 58 ++
.../hooks/useCreatePermissionSetMutation.ts | 23 +
.../hooks/useDeletePermissionSetMutation.ts | 20 +
apps/web/src/hooks/useNavItems.ts | 10 +
apps/web/src/hooks/usePermissionLabels.ts | 39 ++
apps/web/src/hooks/usePermissionSetQuery.ts | 20 +
apps/web/src/hooks/usePermissionSetsQuery.ts | 19 +
.../hooks/useUpdatePermissionSetMutation.ts | 22 +
apps/web/src/hooks/useUpdateUserMutation.ts | 14 +-
apps/web/src/route-tree.ts | 67 +++
.../permission-sets/$permissionSetId.tsx | 87 +++
.../__tests__/$permissionSetId.test.tsx | 191 +++++++
.../permission-sets/__tests__/create.test.tsx | 89 ++++
.../permission-sets/__tests__/index.test.tsx | 141 +++++
.../_app/admin/permission-sets/create.tsx | 52 ++
.../_app/admin/permission-sets/index.tsx | 111 ++++
.../src/routes/_app/admin/users/$userId.tsx | 55 +-
.../admin/users/__tests__/$userId.test.tsx | 100 +++-
.../admin/users/__tests__/create.test.tsx | 86 ++-
.../src/routes/_app/admin/users/create.tsx | 51 +-
.../__tests__/permission-set-rules.test.ts | 64 +++
.../src/utils/__tests__/permissions.test.ts | 9 +
apps/web/src/utils/permission-set-rules.ts | 45 ++
apps/web/src/utils/permissions.ts | 21 +-
47 files changed, 2997 insertions(+), 295 deletions(-)
create mode 100644 apps/web/src/components/CellSelect/CellSelect.tsx
create mode 100644 apps/web/src/components/CellSelect/__tests__/CellSelect.test.tsx
create mode 100644 apps/web/src/components/CellSelect/index.ts
create mode 100644 apps/web/src/components/PermissionSetForm/PermissionSetForm.tsx
create mode 100644 apps/web/src/components/PermissionSetForm/__tests__/PermissionSetForm.test.tsx
create mode 100644 apps/web/src/components/PermissionSetForm/index.ts
create mode 100644 apps/web/src/components/PermissionSetRulesEditor/PermissionSetRulesEditor.stories.tsx
create mode 100644 apps/web/src/components/PermissionSetRulesEditor/PermissionSetRulesEditor.tsx
create mode 100644 apps/web/src/components/PermissionSetRulesEditor/__tests__/PermissionSetRulesEditor.test.tsx
create mode 100644 apps/web/src/components/PermissionSetRulesEditor/index.ts
create mode 100644 apps/web/src/components/UserPermissionsEditor/PermissionSetChoice.tsx
create mode 100644 apps/web/src/hooks/__tests__/useConfirmPermissionSetDeletion.test.tsx
create mode 100644 apps/web/src/hooks/__tests__/useCreatePermissionSetMutation.test.ts
create mode 100644 apps/web/src/hooks/__tests__/useDeletePermissionSetMutation.test.ts
create mode 100644 apps/web/src/hooks/__tests__/usePermissionLabels.test.ts
create mode 100644 apps/web/src/hooks/__tests__/usePermissionSetQuery.test.ts
create mode 100644 apps/web/src/hooks/__tests__/usePermissionSetsQuery.test.ts
create mode 100644 apps/web/src/hooks/__tests__/useUpdatePermissionSetMutation.test.ts
create mode 100644 apps/web/src/hooks/useConfirmPermissionSetDeletion.ts
create mode 100644 apps/web/src/hooks/useCreatePermissionSetMutation.ts
create mode 100644 apps/web/src/hooks/useDeletePermissionSetMutation.ts
create mode 100644 apps/web/src/hooks/usePermissionLabels.ts
create mode 100644 apps/web/src/hooks/usePermissionSetQuery.ts
create mode 100644 apps/web/src/hooks/usePermissionSetsQuery.ts
create mode 100644 apps/web/src/hooks/useUpdatePermissionSetMutation.ts
create mode 100644 apps/web/src/routes/_app/admin/permission-sets/$permissionSetId.tsx
create mode 100644 apps/web/src/routes/_app/admin/permission-sets/__tests__/$permissionSetId.test.tsx
create mode 100644 apps/web/src/routes/_app/admin/permission-sets/__tests__/create.test.tsx
create mode 100644 apps/web/src/routes/_app/admin/permission-sets/__tests__/index.test.tsx
create mode 100644 apps/web/src/routes/_app/admin/permission-sets/create.tsx
create mode 100644 apps/web/src/routes/_app/admin/permission-sets/index.tsx
create mode 100644 apps/web/src/utils/__tests__/permission-set-rules.test.ts
create mode 100644 apps/web/src/utils/permission-set-rules.ts
diff --git a/apps/web/src/components/CellSelect/CellSelect.tsx b/apps/web/src/components/CellSelect/CellSelect.tsx
new file mode 100644
index 000000000..fefdbd88e
--- /dev/null
+++ b/apps/web/src/components/CellSelect/CellSelect.tsx
@@ -0,0 +1,31 @@
+import { Select } from '@douglasneuroinformatics/libui/components';
+
+type CellSelectProps = {
+ disabled?: boolean;
+ label: string;
+ name: string;
+ onValueChange: (value: string) => void;
+ options: { [key: string]: string };
+ placeholder: string;
+ value: string | undefined;
+};
+
+/** A select drawn flat into a table cell. Its test ids follow libui's form select, keyed on `name`. */
+export const CellSelect = ({ disabled, label, name, onValueChange, options, placeholder, value }: CellSelectProps) => (
+
+);
diff --git a/apps/web/src/components/CellSelect/__tests__/CellSelect.test.tsx b/apps/web/src/components/CellSelect/__tests__/CellSelect.test.tsx
new file mode 100644
index 000000000..311caf094
--- /dev/null
+++ b/apps/web/src/components/CellSelect/__tests__/CellSelect.test.tsx
@@ -0,0 +1,45 @@
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { CellSelect } from '@/components/CellSelect/CellSelect';
+
+import '@/services/i18n';
+
+const onValueChange = vi.fn();
+
+const renderSelect = (value?: string) => {
+ render(
+
+ );
+};
+
+describe('CellSelect', () => {
+ afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ });
+
+ it('should show the placeholder until a value is chosen', () => {
+ renderSelect();
+ expect(screen.getByTestId('action-select-trigger').textContent).toBe('Choose…');
+ });
+
+ it('should report the option chosen, keyed by its value', () => {
+ renderSelect();
+ fireEvent.keyDown(screen.getByTestId('action-select-trigger'), { key: 'Enter' });
+ fireEvent.click(screen.getByTestId('action-select-item-read'));
+ expect(onValueChange).toHaveBeenCalledWith('read');
+ });
+
+ it('should name the trigger by its label, since a table cell has no visible one', () => {
+ renderSelect('create');
+ expect(screen.getByRole('combobox', { name: 'Action' }).textContent).toBe('Create');
+ });
+});
diff --git a/apps/web/src/components/CellSelect/index.ts b/apps/web/src/components/CellSelect/index.ts
new file mode 100644
index 000000000..ede8e193c
--- /dev/null
+++ b/apps/web/src/components/CellSelect/index.ts
@@ -0,0 +1 @@
+export * from './CellSelect';
diff --git a/apps/web/src/components/PermissionSetForm/PermissionSetForm.tsx b/apps/web/src/components/PermissionSetForm/PermissionSetForm.tsx
new file mode 100644
index 000000000..2d760fd3c
--- /dev/null
+++ b/apps/web/src/components/PermissionSetForm/PermissionSetForm.tsx
@@ -0,0 +1,103 @@
+import { useState } from 'react';
+import type { ReactNode } from 'react';
+
+import { Form } from '@douglasneuroinformatics/libui/components';
+import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
+import { $CreatePermissionSetData } from '@opendatacapture/schemas/permission-set';
+import type { PermissionSet } from '@opendatacapture/schemas/permission-set';
+
+import { PermissionSetRulesEditor } from '@/components/PermissionSetRulesEditor';
+import { createRuleDraft, isIncompleteRuleDraft, withRuleDrafts } from '@/utils/permission-set-rules';
+import type { PermissionSetRuleDraft } from '@/utils/permission-set-rules';
+
+type PermissionSetFormProps = {
+ /** The set being edited; omitted when creating one. */
+ initialValues?: Pick;
+ /** Resolves to a message to show on the form when the save fails, or nothing when it succeeds. */
+ onSubmit: (data: $CreatePermissionSetData) => Promise;
+ /** A button rendered beside the submit button, such as one deleting the set being edited. */
+ secondaryAction?: ReactNode;
+ submitLabel: string;
+};
+
+/** The name and rules of a permission set, for creating one or editing it. */
+export const PermissionSetForm = ({
+ initialValues,
+ onSubmit,
+ secondaryAction,
+ submitLabel
+}: PermissionSetFormProps) => {
+ const { t } = useTranslation();
+ const [rules, setRules] = useState(initialValues?.rules ?? []);
+ const [drafts, setDrafts] = useState([createRuleDraft()]);
+ const [highlightIncomplete, setHighlightIncomplete] = useState(false);
+
+ return (
+
)}
{isManageAll && (
-
-
-
- {t({
- en: "Manage (All) makes this user an administrator: they can read and change every group's data, manage every user's account and permissions, and create instruments, which can run code on the server.",
- es: 'Gestionar (todo) convierte a este usuario en administrador: puede consultar y modificar los datos de todos los grupos, gestionar la cuenta y los permisos de cada usuario, y crear instrumentos, los cuales pueden ejecutar código en el servidor.',
- fr: 'Gérer (Tout) fait de cet utilisateur un administrateur, qui peut consulter et modifier les données de tous les groupes, gérer le compte et les autorisations de chaque utilisateur, et créer des instruments, lesquels peuvent exécuter du code sur le serveur.'
- })}
-
-
+
+ {t({
+ en: "Manage (All) makes this user an administrator: they can read and change every group's data, manage every user's account and permissions, and create instruments, which can run code on the server.",
+ es: 'Gestionar (todo) convierte a este usuario en administrador: puede consultar y modificar los datos de todos los grupos, gestionar la cuenta y los permisos de cada usuario, y crear instrumentos, los cuales pueden ejecutar código en el servidor.',
+ fr: 'Gérer (Tout) fait de cet utilisateur un administrateur, qui peut consulter et modifier les données de tous les groupes, gérer le compte et les autorisations de chaque utilisateur, et créer des instruments, lesquels peuvent exécuter du code sur le serveur.'
+ })}
+
)}
)}
diff --git a/apps/web/src/components/UserPermissionsEditor/WarningCallout.tsx b/apps/web/src/components/UserPermissionsEditor/WarningCallout.tsx
new file mode 100644
index 000000000..745927e59
--- /dev/null
+++ b/apps/web/src/components/UserPermissionsEditor/WarningCallout.tsx
@@ -0,0 +1,25 @@
+import type React from 'react';
+
+import { cn } from '@douglasneuroinformatics/libui/utils';
+import { TriangleAlertIcon } from 'lucide-react';
+
+type WarningCalloutProps = {
+ children: React.ReactNode;
+ className?: string;
+ 'data-testid': string;
+};
+
+/** A warning about what saving the permissions as they stand will do, announced as soon as it appears. */
+export const WarningCallout = ({ children, className, 'data-testid': testId }: WarningCalloutProps) => (
+