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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions .agents/docs/architecture/auth-and-permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,23 @@ that the controller actually forwards `@CurrentUser('ability')` — `EntityOpera
is optional, so a controller that simply never passes it compiles and runs.

**A defined ability holding no rule for the queried subject does not produce a deny-all filter —
CASL's `accessibleBy` throws `ForbiddenError`.** That error is not an `HttpException`, so the
`accessibleQuery` throws CASL's `ForbiddenError`.** That error is not an `HttpException`, so the
global exception filter renders it as a 500. It is reachable whenever the route guard names a
different subject than the service queries: a STANDARD user passes a `read Instrument` guard but
holds no rule on `InstrumentRecord`, so a record query made on their behalf throws. The throw is
deliberate — `accessibleQuery` is kept as-is rather than returning a deny-all filter — so where
such a caller is legitimate, guard the query with `ability.can(action, subject)` and return the
empty result instead; `findInstrumentIdsBySubject` in `src/instruments/instruments.service.ts` is
the reference example.
the reference example. `ability.can` refuses in every case where the query would throw, so the
guard always pre-empts it.

The throw is `accessibleQuery`'s own. `@casl/prisma` 2's `accessibleBy(...).ofType(...)` no longer
throws: it returns `{ OR: [] }`, which Prisma ignores once it is nested in an `AND`
(prisma/prisma#17367, reproducible on this MongoDB stack), so passed on it would lift the
restriction rather than apply it. `accessibleQuery` throws on that marker instead, under exactly the
condition `@casl/prisma` 1 threw. That is also why the client in `src/core/prisma.ts` carries no
`createCaslExtension()`, which upstream offers to turn the marker into an empty result: the marker
never reaches Prisma. Call `accessibleBy` only through `accessibleQuery`.

One call site uses `{ ...accessibleQuery(...), id: subjectId }` instead of `AND: [...]`
(`src/subjects/subjects.service.ts`). Spreading merges keys and will silently lose a condition if
Expand Down Expand Up @@ -208,8 +217,8 @@ add it to the `$GroupScopableSubjectName` exclusion.

- `src/auth/__tests__/ability.factory.test.ts` — role-to-rule mapping. Each case asserts an allow
_and_ a deny.
- `src/auth/__tests__/ability.utils.test.ts` — pins the `undefined` ability behavior and
`forcedAppSubject`.
- `src/auth/__tests__/ability.utils.test.ts` — pins the `undefined` ability behavior, the
no-rule throw (including `cannot` rules) and `forcedAppSubject`, against the real CASL.
- `src/auth/guards/__tests__/jwt-auth.guard.test.ts` — the guard's own branches.

Service tests mock the Prisma layer, so they verify the arguments passed to the model, not the
Expand Down
51 changes: 28 additions & 23 deletions .agents/docs/architecture/testing-strategy.md

Large diffs are not rendered by default.

28 changes: 14 additions & 14 deletions .agents/docs/playbooks/add-vitest-project.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ of your file means the config is absent.
```ts
import { defineProject, mergeConfig } from 'vitest/config';

import baseConfig from '../../vitest.config';
import { baseConfig } from '../../vitest.config';

export default mergeConfig(
baseConfig,
Expand All @@ -52,6 +52,14 @@ of your file means the config is absent.
rather than the repo. Every project file uses `defineProject` — `defineConfig` belongs to the root
config alone.

Merge the named `baseConfig`, never the root's default export. The default export adds `coverage`
and `projects`, and Vitest reads a `projects` field inside a project config as a nested set of
projects, re-resolved against the package directory, so every run stops before collecting a file:

```
Error: No projects were found in "packages/schemas/vitest.config.ts". Make sure your configuration is correct.
```

3. **Name the project after the directory.** The `name` field is the string `--project` takes, and it
is the directory basename everywhere except `runtime/v1`, whose project is `runtime-v1` because
`v1` names nothing on its own. Kebab-case, no `@opendatacapture/` scope.
Expand All @@ -76,19 +84,11 @@ of your file means the config is absent.
`packages/runtime-bundler` and `packages/runtime-meta` do — a `test/` directory outside the
tsconfig `include` is type-checked by nothing.

6. **Add the `test` script, and do not rely on it.** `"test": "vitest"` under `packages/` and
`runtime/`, `"test": "env-cmd -f ../../.env vitest"` for an app, matching every project that
already exists. Running it does not work today: `mergeConfig` inherits the root `test.projects`
globs along with everything else, and started from inside the package they re-resolve against that
directory and match nothing —

```
Error: No projects were found. Make sure your configuration is correct. The projects definition: […]
```

Scope a run from the repo root instead: `pnpm exec vitest --project <name>`. Root `pnpm test` runs
the projects directly and never reads this script; `vitest` is a root devDependency, so the package
declares nothing.
6. **Add the `test` script.** `"test": "vitest"` under `packages/` and `runtime/`,
`"test": "env-cmd -f ../../.env vitest"` for an app, matching every project that already exists.
`pnpm --filter @opendatacapture/<pkg> test` runs this one project from the package directory. Root
`pnpm test` runs the projects directly and never reads this script; `vitest` is a root
devDependency, so the package declares nothing.

7. **Place the test file inside the inherited glob.** That is `**/*.{test,spec}.?(c|m)[jt]s?(x)`,
relative to the package `root`; the house convention is a `__tests__/` folder beside the subject,
Expand Down
2 changes: 1 addition & 1 deletion .agents/docs/playbooks/promote-to-react-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ so nothing that passed lint in `apps/web` fails on style here; what breaks is re

6. **Repoint every `@/…` import.** `packages/react-core/tsconfig.json` declares one path mapping,
`/runtime/v1/*` — there is no `@/*` here, so every `@/…` specifier is a module-resolution error.
Storybook agrees: its `vite.config.js` resolves `@` through a `customResolver` keyed on the
Storybook agrees: its `vite.config.js` resolves `@` through a `source-root-alias` plugin keyed on the
importing file and returns `null` for anything outside `apps/web/src` and `apps/playground/src`.
`@/components/*` becomes a relative sibling import. Everything else — `@/store`, `@/hooks/*`,
`@/config`, `@/services/*`, `@/utils/*` — does not move; it becomes a prop or callback per step 5.
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ node_modules

# testing
coverage
.vitest

# build
build
Expand Down
6 changes: 3 additions & 3 deletions apps/api/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,9 +253,9 @@ Provider overrides are deliberately not offered — nothing needs one yet. Addin

The `apps/api/vitest.config.ts` `libnest` plugin is required — it applies the SWC decorator
transforms, without which `@Injectable`/`@Body` metadata does not exist at runtime. The config
confines SWC to files under `apps/api` and leaves workspace packages to esbuild, as in every other
project. Letting SWC transform them too makes the merged coverage report count each of their
functions twice.
confines SWC to files under `apps/api` and leaves workspace packages to Vite's Oxc transform, as in
every other project; its `oxc.exclude` keeps Oxc off `apps/api` itself. Letting SWC transform them
too makes the merged coverage report count each of their functions twice.

Ignore `apps/api/.vscode/test/*` — it is stale, never runs, and references modules that no longer
exist.
Expand Down
19 changes: 9 additions & 10 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,20 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.1141.0",
"@aws-sdk/s3-request-presigner": "^3.1141.0",
"@casl/ability": "^6.7.5",
"@casl/prisma": "^1.5.1",
"@casl/ability": "catalog:",
"@casl/prisma": "^2.0.2",
"@douglasneuroinformatics/libcrypto": "catalog:",
"@douglasneuroinformatics/libjs": "catalog:",
"@douglasneuroinformatics/libnest": "^9.0.0",
"@douglasneuroinformatics/libnest": "^10.0.0",
"@douglasneuroinformatics/libpasswd": "catalog:",
"@douglasneuroinformatics/libstats": "catalog:",
"@faker-js/faker": "~10.2.0",
"@faker-js/faker": "^10.6.0",
"@nestjs/axios": "^12.0.1",
"@nestjs/common": "^11.2.6",
"@nestjs/core": "^11.2.6",
"@nestjs/common": "^12.1.1",
"@nestjs/core": "^12.1.1",
"@nestjs/jwt": "^12.0.2",
"@nestjs/passport": "^12.0.0",
"@nestjs/platform-express": "^11.2.6",
"@nestjs/platform-fastify": "^11.2.6",
"@nestjs/platform-fastify": "^12.1.1",
"@nestjs/throttler": "^6.7.1",
"@opendatacapture/demo": "workspace:*",
"@opendatacapture/instrument-bundler": "workspace:*",
Expand All @@ -54,13 +53,13 @@
"nodemailer": "catalog:",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.1.14",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"ts-pattern": "workspace:ts-pattern__5.x@*",
"zod": "workspace:zod__3.x@*"
},
"devDependencies": {
"@nestjs/testing": "^11.2.6",
"@nestjs/testing": "^12.1.1",
"@types/express": "^5.0.0",
"@types/passport": "^1.0.17",
"@types/passport-jwt": "^4.0.1",
Expand Down
23 changes: 23 additions & 0 deletions apps/api/src/auth/__tests__/ability.factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,29 @@ describe('AbilityFactory', () => {
expect(ability.can('manage', subject('Group', { id: 'group-2' }) as any)).toBe(false);
});

// CASL 7 treats conditions that semantically match everything as no conditions at all, which in a
// `where` means no restriction. These pin that a group condition, even over no groups, is not one.
it.each([
['InstrumentRecord', ['group-1', 'group-2'], { groupId: { in: ['group-1', 'group-2'] } }],
['Session', ['group-1', 'group-2'], { groupId: { in: ['group-1', 'group-2'] } }],
['Subject', ['group-1', 'group-2'], { groupIds: { hasSome: ['group-1', 'group-2'] } }],
['User', ['group-1', 'group-2'], { groupIds: { hasSome: ['group-1', 'group-2'] } }],
['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);

expect(accessibleQuery(ability, 'read', modelName)).toStrictEqual({ OR: [condition] });
});

it('should restrict series deletion to the manager group', () => {
const ability = abilityFactory.createForPayload({
additionalPermissions: undefined,
Expand Down
61 changes: 47 additions & 14 deletions apps/api/src/auth/__tests__/ability.utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,58 @@
import { describe, expect, it, vi } from 'vitest';
import { ForbiddenError } from '@casl/ability';
import { describe, expect, it } from 'vitest';

import { accessibleQuery, createAppAbility, forcedAppSubject } from '../ability.utils';

const accessibleBy = vi.hoisted(() => vi.fn());

vi.mock('@casl/prisma/runtime', () => ({
createAccessibleByFactory: () => accessibleBy
}));

describe('accessibleQuery', () => {
it('should return an empty object if ability is undefined', () => {
expect(accessibleQuery(undefined, 'manage', 'User')).toStrictEqual({});
expect(accessibleBy).not.toHaveBeenCalled();
});
it('should call accessibleBy with the correct parameters and return the result of accessibleBy for the model', () => {
accessibleBy.mockReturnValueOnce({
User: 'QUERY'

it('should return an empty object for an unconditional rule, since it restricts nothing', () => {
const ability = createAppAbility([{ action: 'manage', subject: 'all' }]);
expect(accessibleQuery(ability, 'read', 'Subject')).toStrictEqual({});
});

it('should return the conditions of the rules granting the action on the model', () => {
const ability = createAppAbility([
{ action: 'read', conditions: { groupIds: { hasSome: ['group-1'] } }, subject: 'Subject' },
{ action: 'read', conditions: { groupId: { in: ['group-1'] } }, subject: 'Session' }
]);
expect(accessibleQuery(ability, 'read', 'Subject')).toStrictEqual({ OR: [{ groupIds: { hasSome: ['group-1'] } }] });
});

it('should throw a ForbiddenError when no rule grants the action on the model, rather than filter', () => {
const ability = createAppAbility([{ action: 'read', subject: 'Session' }]);
expect(() => accessibleQuery(ability, 'read', 'Subject')).toThrow(ForbiddenError);
expect(() => accessibleQuery(ability, 'read', 'Subject')).toThrow(`It's not allowed to run "read" on "Subject"`);
});

it('should throw when an unconditional cannot rule outranks every can rule', () => {
const ability = createAppAbility([
{ action: 'read', conditions: { groupIds: { hasSome: ['group-1'] } }, subject: 'Subject' },
{ action: 'read', inverted: true, subject: 'Subject' }
]);
expect(() => accessibleQuery(ability, 'read', 'Subject')).toThrow(ForbiddenError);
});

it('should throw when the only rules for the model are conditional cannot rules', () => {
const ability = createAppAbility([
{ action: 'read', conditions: { groupIds: { hasSome: ['group-1'] } }, inverted: true, subject: 'Subject' }
]);
expect(() => accessibleQuery(ability, 'read', 'Subject')).toThrow(ForbiddenError);
});

// `ability.can` refuses here, since CASL 7 reads a cannot rule matching everything as unconditional,
// but the query is a filter that matches nothing, which is what @casl/prisma 1 returned too.
it('should filter rather than throw when a cannot rule matching everything outranks a can rule', () => {
const ability = createAppAbility([
{ action: 'read', conditions: { groupIds: { hasSome: ['group-1'] } }, subject: 'Subject' },
{ action: 'read', conditions: {}, inverted: true, subject: 'Subject' }
]);
expect(ability.can('read', 'Subject')).toBe(false);
expect(accessibleQuery(ability, 'read', 'Subject')).toStrictEqual({
OR: [{ AND: [{ groupIds: { hasSome: ['group-1'] } }, { NOT: {} }] }]
});
const ability = vi.fn();
expect(accessibleQuery(ability as any, 'manage', 'User')).toStrictEqual('QUERY');
expect(accessibleBy).toHaveBeenCalledExactlyOnceWith(ability, 'manage');
});
});

Expand Down
24 changes: 15 additions & 9 deletions apps/api/src/auth/ability.utils.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
import { detectSubjectType, subject } from '@casl/ability';
import { createPrismaAbility } from '@casl/prisma';
import type { PrismaQuery } from '@casl/prisma';
import { createAccessibleByFactory } from '@casl/prisma/runtime';
import { detectSubjectType, ForbiddenError, subject } from '@casl/ability';
import { accessibleBy, createPrismaAbility } from '@casl/prisma';
import type { AppSubject, Prisma } from '@prisma/client';

import type { PrismaModelWhereInputMap } from '@/core/prisma';

import type { AppAbilities, AppAbility, AppAction, AppSubjectModels, AppSubjectName, Permission } from './auth.types';

const accessibleBy = createAccessibleByFactory<PrismaModelWhereInputMap, PrismaQuery>();
import type { AppAbility, AppAction, AppSubjectModels, AppSubjectName, Permission } from './auth.types';

export function detectAppSubject(obj: { [key: string]: any }) {
if (typeof obj.__modelName === 'string') {
Expand All @@ -28,7 +24,7 @@ export function forcedAppSubject<TSubjectName extends Exclude<AppSubjectName, 'a
}

export function createAppAbility(permissions: Permission[]): AppAbility {
return createPrismaAbility<AppAbilities>(permissions, {
return createPrismaAbility<AppAbility>(permissions, {
detectSubjectType: detectAppSubject
});
}
Expand All @@ -41,5 +37,15 @@ export function accessibleQuery<T extends Prisma.ModelName>(
if (!ability) {
return {};
}
return accessibleBy(ability, action)[modelName]!;
const query = accessibleBy(ability, action).ofType(modelName);
// `ofType` returns `{ OR: [] }` exactly when no rule grants the action on the model, which is when
// @casl/prisma 1 threw this error. Prisma ignores an empty `OR` nested in an `AND`
// (prisma/prisma#17367), so passing it on would lift the restriction instead of applying it.
if (query.OR?.length === 0) {
const error = ForbiddenError.from(ability).setMessage(`It's not allowed to run "${action}" on "${modelName}"`);
error.action = action;
error.subject = error.subjectType = modelName;
throw error;
}
return query;
}
9 changes: 4 additions & 5 deletions apps/api/src/auth/auth.types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { PureAbility } from '@casl/ability';
import type { RawRuleOf } from '@casl/ability';
import type { Ability, RawRuleOf } from '@casl/ability';
import type { PrismaQuery, Subjects } from '@casl/prisma';
import { Prisma } from '@prisma/client';
import type { DefaultSelection } from '@prisma/client/runtime/library';
Expand All @@ -16,8 +15,8 @@ type AppSubjectName = Extract<AppSubjects, string>;

type AppAbilities = [AppAction, AppSubjects];

type AppAbility = PureAbility<AppAbilities, PrismaQuery>;
type AppAbility = Ability<AppAbilities, PrismaQuery>;

type Permission = RawRuleOf<PureAbility<[AppAction, AppSubjectName], PrismaQuery>>;
type Permission = RawRuleOf<Ability<[AppAction, AppSubjectName], PrismaQuery>>;

export type { AppAbilities, AppAbility, AppAction, AppSubjectModels, AppSubjectName, Permission };
export type { AppAbility, AppAction, AppSubjectModels, AppSubjectName, Permission };
4 changes: 2 additions & 2 deletions apps/api/src/auth/guards/jwt-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PureAbility } from '@casl/ability';
import { Ability } from '@casl/ability';
import { LoggingService } from '@douglasneuroinformatics/libnest';
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import type { ExecutionContext } from '@nestjs/common';
Expand Down Expand Up @@ -55,7 +55,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') {

const ability = request.user?.ability;

if (!(ability instanceof PureAbility)) {
if (!(ability instanceof Ability)) {
this.loggingService.error('User property of request does not include expected AppAbility');
throw new InternalServerErrorException();
}
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/demo/__tests__/demo.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { InternalServerErrorException } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { DEMO_GROUPS, DEMO_USERS } from '@opendatacapture/demo';
import type { $CreateGroupData } from '@opendatacapture/schemas/group';
import { $Sex } from '@opendatacapture/schemas/subject';
import { encodeScopedSubjectId, generateSubjectHash } from '@opendatacapture/subject-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

Expand Down Expand Up @@ -149,6 +150,12 @@ describe('DemoService', () => {
expect(subject.id).toBe(await generateSubjectHash({ dateOfBirth, firstName, lastName, sex }));
});

it('should draw dummy subjects from every sex the subject schema accepts and no other', async () => {
await demoService.init({ dummySubjectCount: 20, recordsPerSubject: 0 });
const sexes = subjectsService.create.mock.calls.map(([subject]) => subject.sex);
expect(new Set(sexes)).toStrictEqual(new Set($Sex.options));
});

it('should number dummy subjects in a custom-id group in sequence under the group prefix', async () => {
vi.spyOn(Math, 'random').mockReturnValue(LAST_GROUP);
await demoService.init({ dummySubjectCount: 2, recordsPerSubject: 0 });
Expand Down
5 changes: 3 additions & 2 deletions apps/api/src/demo/demo.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { randomValue, toUpperCase } from '@douglasneuroinformatics/libjs';
import { randomValue } from '@douglasneuroinformatics/libjs';
import { InjectPrismaClient, LoggingService } from '@douglasneuroinformatics/libnest';
import { faker } from '@faker-js/faker';
import { Injectable, InternalServerErrorException } from '@nestjs/common';
Expand All @@ -14,6 +14,7 @@ import happinessQuestionnaireWithConsent from '@opendatacapture/instrument-libra
import type { FormInstrument, Language } from '@opendatacapture/runtime-core';
import type { WithID } from '@opendatacapture/schemas/core';
import type { Group } from '@opendatacapture/schemas/group';
import { $Sex } from '@opendatacapture/schemas/subject';
import { encodeScopedSubjectId, generateSubjectHash } from '@opendatacapture/subject-utils';

import type { RuntimePrismaClient } from '@/core/prisma';
Expand Down Expand Up @@ -106,7 +107,7 @@ export class DemoService {
dateOfBirth: faker.date.birthdate(),
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
sex: toUpperCase(faker.person.sexType())
sex: faker.helpers.arrayElement($Sex.options)
};

let subjectId: string;
Expand Down
Loading
Loading