From d5c3126e1000cfbb3eea4c8ea14815d7fbbbed08 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 00:21:32 +0000 Subject: [PATCH 1/9] refactor(organization): require administrator evidence for create commands createDepartment, createTeam, and createFieldOfStudy take OrganizationAdministratorEvidence instead of the OrganizationActor union. requireOrganizationAdministrator is its only constructor, so a forged or member actor no longer type checks, and the adapter's runtime re-check (decodeOrganizationActor, authorizeOrganizationActor) is deleted. The unused resolveActor option and organizationActorFrom are removed. The stored actor_json keeps its shape, and the authorization-rules proof observes the same tags. Pilot of docs/specs/authz-evidence.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/backend/src/authority.ts | 6 - apps/backend/src/http-api/transport.test.ts | 1 - apps/backend/src/organization/http.test.ts | 29 +--- apps/backend/src/organization/http.ts | 42 +++--- .../organization/team-interest.http.test.ts | 5 - apps/backend/src/router.ts | 5 - docs/constructs.md | 2 + docs/constructs/authority-evidence.md | 42 ++++++ docs/specs/authz-evidence.md | 97 +++++++++++++ ...authorization-rules-postgres-proof-main.ts | 19 +-- .../organization-postgres-proof-main.ts | 16 ++- packages/database/src/database.test.ts | 78 ++++++----- .../organization/administration-postgres.ts | 21 +-- packages/domain/src/organization/AGENTS.md | 6 + .../src/organization/administration.test.ts | 20 +-- .../domain/src/organization/administration.ts | 25 +--- .../organization/authority-evidence.test.ts | 130 ++++++++++++++++++ packages/domain/src/organization/authority.ts | 59 +++++++- packages/domain/src/organization/service.ts | 13 +- tools/conventions/src/constructs.ts | 2 + 20 files changed, 435 insertions(+), 183 deletions(-) create mode 100644 docs/constructs/authority-evidence.md create mode 100644 docs/specs/authz-evidence.md create mode 100644 packages/domain/src/organization/authority-evidence.test.ts diff --git a/apps/backend/src/authority.ts b/apps/backend/src/authority.ts index cfbe7c4e..f12ff506 100644 --- a/apps/backend/src/authority.ts +++ b/apps/backend/src/authority.ts @@ -15,7 +15,6 @@ import { } from "@vektorprogrammet/domain/identity"; import type { DepartmentId, - OrganizationActor, OrganizationAuthorityInstant, OrganizationPersonAuthority, PersonId, @@ -23,7 +22,6 @@ import type { } from "@vektorprogrammet/domain/organization"; import { mapOrganizationAuthorityToDepartmentActor, - mapOrganizationAuthorityToOrganizationActor, mapOrganizationAuthorityToProfileRole, Organization, OrganizationAuthorityInstantSchema, @@ -435,10 +433,6 @@ export const admissionActorForDepartment = ( departmentId: DepartmentId, ): AdmissionPeriodActor => departmentActorFor(authority, "admissions.periods", departmentId); -/** Active global administrator maps to OrganizationAdministrator; everyone else Member. */ -export const organizationActorFrom = (authority: OrganizationPersonAuthority): OrganizationActor => - mapOrganizationAuthorityToOrganizationActor(authority); - /** Coarse dashboard role from the full projection (spec 0055 §Profile). * Returns the raw Decision so the adapter can translate Deny(reason) into its * typed denial instead of an ambiguous collapse. */ diff --git a/apps/backend/src/http-api/transport.test.ts b/apps/backend/src/http-api/transport.test.ts index 62b8c8a1..132e6952 100644 --- a/apps/backend/src/http-api/transport.test.ts +++ b/apps/backend/src/http-api/transport.test.ts @@ -116,7 +116,6 @@ describe("native request schema error transport", () => { const response = yield* makeOrganizationTestHttp( { config: backendTestConfig.organization, - resolveActor: unreachable, resolveAuthority: unreachable, }, securityServices, diff --git a/apps/backend/src/organization/http.test.ts b/apps/backend/src/organization/http.test.ts index 69624a34..e7078d94 100644 --- a/apps/backend/src/organization/http.test.ts +++ b/apps/backend/src/organization/http.test.ts @@ -1,6 +1,5 @@ import { backendDatabase } from "../../test/database.js"; import { IdentitySnapshot, OAuthCredentialAuthority } from "@vektorprogrammet/database"; -import { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; import { Identity, IdentityActor, @@ -21,14 +20,12 @@ import { DepartmentCreatedObservationSchema, TeamCreatedObservationSchema, FieldOfStudyCreatedObservationSchema, - OrganizationActorSchema, - OrganizationRoleDenied, OrganizationCommandConflict, OrganizationPersistenceError, OrganizationInvalidReference, } from "@vektorprogrammet/domain/organization"; import { makeNativeValidationError } from "@vektorprogrammet/http-api/http-semantics"; -import { Predicate, DateTime, Effect, Layer, Schema } from "effect"; +import { DateTime, Effect, Layer, Schema } from "effect"; import { describe, expect, it } from "@effect/vitest"; import { decodeOrganizationApiConfig } from "./config.js"; import { jsonText } from "../http-api/problem.js"; @@ -148,19 +145,10 @@ const organization = { }), createDepartment: ( command: Parameters[0], - actor: Parameters[1], + _administrator: Parameters[1], ) => { createCalls += 1; - if (Predicate.isTagged(actor, "OrganizationMember")) { - return Effect.fail( - new OrganizationRoleDenied({ - actorPersonId: actor.personId, - requiredRole: "OrganizationAdministrator", - }), - ); - } - if (command.name === "Conflict") { return Effect.fail(new OrganizationCommandConflict({ commandId: command.commandId })); } @@ -276,19 +264,6 @@ const services = Layer.mergeAll( const http = makeOrganizationApiHttp( { config, - resolveActor: (request) => { - const cookieHeader = request.headers.get("cookie"); - - if (cookieHeader === null) { - return Effect.fail(new UnauthenticatedActor({ message: "authentication required" })); - } - - return Effect.succeed( - cookieHeader.includes(`better-auth.session_token=${ADMIN_SESSION}`) - ? OrganizationActorSchema.members[0].make({ personId: PersonId.make("person-admin") }) - : OrganizationActorSchema.members[1].make({ personId: PersonId.make("person-member") }), - ); - }, resolveAuthority: () => Effect.succeed({ personId: PersonId.make("person-admin"), diff --git a/apps/backend/src/organization/http.ts b/apps/backend/src/organization/http.ts index ee61f7d4..bef0b46b 100644 --- a/apps/backend/src/organization/http.ts +++ b/apps/backend/src/organization/http.ts @@ -20,10 +20,11 @@ import { Organization, OrganizationCommandId, OrganizationLifecycleCommand, + mapOrganizationAuthorityToOrganizationActor, + requireOrganizationAdministrator, SemesterId, type TeamId, TeamJsonSchema, - type OrganizationActor, type OrganizationCommandFailure, type OrganizationLifecycleFailure, type OrganizationPersonAuthority, @@ -61,10 +62,9 @@ import { Problem, type StrongETag, } from "@vektorprogrammet/http-api/http-semantics"; -import { DateTime, Effect, flow, Match, Option, Predicate, Schema } from "effect"; +import { DateTime, Effect, flow, Match, Option, Predicate, Result, Schema } from "effect"; import { HttpApiBuilder } from "effect/unstable/httpapi"; import { - organizationActorFrom, resolveRequestCredentialInTransaction, resolveRequestPersonAuthorityInTransaction, type OrganizationResolutionError, @@ -102,14 +102,6 @@ import type { OrganizationApiConfig } from "./config.js"; export interface OrganizationApiHttpOptions { readonly config: OrganizationApiConfig; - /** Cookie -> Organization projection -> OrganizationAdministrator|Member. */ - readonly resolveActor: ( - request: Request, - ) => Effect.Effect< - OrganizationActor, - IdentityEngineError | UnauthenticatedActor | OrganizationResolutionError, - Identity | OAuthCredentialAuthority | Organization - >; /** * Cookie -> full 0055 authority projection for leader-scoped admin reads * (specs 0059/0060). One captured authorizationInstant per request. @@ -414,36 +406,40 @@ const authorizeCreate = (input: { }) => Effect.gen(function* () { const resolved = yield* resolveRequestPersonAuthorityInTransaction(input.request, {}); - const actor = organizationActorFrom(resolved.authority); + const personId = resolved.authority.personId; + const administrator = requireOrganizationAdministrator(resolved.authority); + // The AccessSpec answers a denial, with its concealment; the evidence is what the command takes. yield* authorizePerson( { spec: Option.getOrThrow(reflectAccessSpec(input.endpoint)), credential: resolved.credential, - personId: actor.personId, + personId, resolution: { selection: "ExactlyOne", contexts: [ genericContext({ domainId: "organization", - authorityVersion: `organization:${actor._tag}`, + authorityVersion: `organization:${mapOrganizationAuthorityToOrganizationActor(resolved.authority)._tag}`, }), ], }, - grantScopes: Predicate.isTagged(actor, "OrganizationAdministrator") ? [Scope.Global()] : [], + grantScopes: Result.isSuccess(administrator) ? [Scope.Global()] : [], now: resolved.authorizationInstant, }, input.presentation, ); + const evidence = yield* Effect.fromResult(administrator); + const identity = yield* httpIdentity({ - credentialSubject: `Person:${actor.personId}`, + credentialSubject: `Person:${personId}`, qualifiedOperationId: input.operationId, normalizedTarget: input.target, idempotencyKey: input.idempotencyKey, }); - return { actor, identity }; + return { administrator: evidence, identity }; }); const createDepartment = (request: Request, input: OrganizationApiHttpOptions) => @@ -461,7 +457,7 @@ const createDepartment = (request: Request, input: OrganizationApiHttpOptions) = // Domain and credential failures are mapped after the executor, with its receipt failures. const outcome = yield* executeNativeHttpCommandPostgres( Effect.gen(function* () { - const { actor, identity } = yield* authorizeCreate({ + const { administrator, identity } = yield* authorizeCreate({ request, endpoint: CreateDepartmentEndpoint, operationId, @@ -482,7 +478,7 @@ const createDepartment = (request: Request, input: OrganizationApiHttpOptions) = commandId: OrganizationCommandId.make(identity.commandId), ...payload, }), - actor, + administrator, ), ).pipe( Effect.flatMap(({ observation }) => @@ -532,7 +528,7 @@ const createTeam = (request: Request, input: OrganizationApiHttpOptions) => // Domain and credential failures are mapped after the executor, with its receipt failures. const outcome = yield* executeNativeHttpCommandPostgres( Effect.gen(function* () { - const { actor, identity } = yield* authorizeCreate({ + const { administrator, identity } = yield* authorizeCreate({ request, endpoint: CreateTeamEndpoint, operationId, @@ -553,7 +549,7 @@ const createTeam = (request: Request, input: OrganizationApiHttpOptions) => commandId: OrganizationCommandId.make(identity.commandId), ...payload, }), - actor, + administrator, ), ).pipe( Effect.flatMap(({ observation }) => @@ -603,7 +599,7 @@ const createFieldOfStudy = (request: Request, input: OrganizationApiHttpOptions) // Domain and credential failures are mapped after the executor, with its receipt failures. const outcome = yield* executeNativeHttpCommandPostgres( Effect.gen(function* () { - const { actor, identity } = yield* authorizeCreate({ + const { administrator, identity } = yield* authorizeCreate({ request, endpoint: CreateFieldOfStudyEndpoint, operationId, @@ -624,7 +620,7 @@ const createFieldOfStudy = (request: Request, input: OrganizationApiHttpOptions) commandId: OrganizationCommandId.make(identity.commandId), ...payload, }), - actor, + administrator, ), ).pipe( Effect.flatMap(({ observation }) => diff --git a/apps/backend/src/organization/team-interest.http.test.ts b/apps/backend/src/organization/team-interest.http.test.ts index e4e54c2e..b5376d29 100644 --- a/apps/backend/src/organization/team-interest.http.test.ts +++ b/apps/backend/src/organization/team-interest.http.test.ts @@ -11,7 +11,6 @@ import { TeamId, SemesterId, MembershipId, - OrganizationActorSchema, DepartmentJsonSchema, Organization, PersonId, @@ -280,10 +279,6 @@ const services = Layer.mergeAll( const http = makeOrganizationApiHttp( { config, - resolveActor: () => - Effect.succeed( - OrganizationActorSchema.members[1].make({ personId: PersonId.make("person-member") }), - ), resolveAuthority: (request) => { const cookie = request.headers.get("cookie"); diff --git a/apps/backend/src/router.ts b/apps/backend/src/router.ts index 230766ff..66ad92ce 100644 --- a/apps/backend/src/router.ts +++ b/apps/backend/src/router.ts @@ -22,7 +22,6 @@ import { admissionActorForAuthority } from "./admission/http-context.js"; import { AdmissionOutcomesApiHandlers } from "./admission/outcome-http.js"; import { DirectoryApiHandlers } from "./directory/http.js"; import { - organizationActorFrom, recruitmentBoardActorFrom, resolveAuthenticatedPerson, resolveAuthenticatedPersonAtInstant, @@ -190,10 +189,6 @@ export const ExternalNativeApiRouterLive = ( }), OrganizationApiHandlers({ config: config.organization, - resolveActor: (request) => - resolveRequestPersonAuthority(request, { now: options.now }).pipe( - Effect.map(organizationActorFrom), - ), resolveAuthority: (request) => resolveRequestPersonAuthority(request, { now: options.now }), }), DirectoryApiHandlers( diff --git a/docs/constructs.md b/docs/constructs.md index b12e2565..abcb8cbb 100644 --- a/docs/constructs.md +++ b/docs/constructs.md @@ -17,6 +17,8 @@ Tag a construct only when at least 2 modules outside its own module and the test - [`encodePathIdentity`](constructs/http-transport.md#encodepathidentity): Encodes one decoded identity as an uppercase RFC 3986 path segment. - [`normalizeTarget`](constructs/http-transport.md#normalizetarget): Fills a route template with its encoded identities; a missing identity is a malformed request. - [`deriveHttpIdentity`](constructs/http-transport.md#derivehttpidentity): Derives the private storage digest and domain command ID from the identity tuple. +- [authority-evidence](constructs/authority-evidence.md): Checks a resolved authority and returns the evidence that a command which needs that authority takes; nothing else constructs it. + - [`requireOrganizationAdministrator`](constructs/authority-evidence.md#requireorganizationadministrator): Checks that a resolved authority holds active global administration, and returns the evidence that the Organization administration commands require. - [http-problem](constructs/http-problem.md): Answers a native HTTP request with a declared problem: failure mapping, credential classification, authorization, and decoding. - [`authorizeAdmissionPerson`](constructs/http-problem.md#authorizeadmissionperson): Evaluates one admission person AccessSpec. - [`returningAuthorization`](constructs/http-problem.md#returningauthorization): Resolves the current person and authorizes one returning-assistant operation on that person's own profile. diff --git a/docs/constructs/authority-evidence.md b/docs/constructs/authority-evidence.md new file mode 100644 index 00000000..32e9b50f --- /dev/null +++ b/docs/constructs/authority-evidence.md @@ -0,0 +1,42 @@ +# authority-evidence + +[//]: # "constructs: generated from the @construct tags and their JSDoc by just constructs write; do not edit" + +Checks a resolved authority and returns the evidence that a command which needs that authority takes; nothing else constructs it. The [index](../constructs.md) lists every category. + +## `requireOrganizationAdministrator` + +Checks that a resolved authority holds active global administration, and returns the evidence that the Organization administration commands require. + +```ts +requireOrganizationAdministrator( + authority: OrganizationPersonAuthority +): Result.Result +``` + +- Inputs: `authority: OrganizationPersonAuthority` +- Output: `Result.Result` +- Errors: `OrganizationRoleDenied` +- Requirements: none +- Side effects: none +- Source: [packages/domain/src/organization/authority.ts:263](../../packages/domain/src/organization/authority.ts#L263) + +**How it works** + +It is the only constructor of `OrganizationAdministratorEvidence`. An active global +administrator yields evidence whose actor names the same person; an inactive or absent grant +yields `OrganizationRoleDenied` for that person. It reads only `globalAdministrator` and +`personId`: memberships, board seats, and delegations confer no organization administration. + +**Use** + +```ts +const administrator = yield* Effect.fromResult(requireOrganizationAdministrator(authority)); +yield* organization.createDepartment(command, administrator); +``` + +**Avoid** + +Passing an `OrganizationActor` to a create command, or checking its tag at the call site: +any module can build an actor, so the command could not trust it. Resolve the authority and +require the evidence here. diff --git a/docs/specs/authz-evidence.md b/docs/specs/authz-evidence.md new file mode 100644 index 00000000..7e4fb97b --- /dev/null +++ b/docs/specs/authz-evidence.md @@ -0,0 +1,97 @@ +# Authorization evidence + +Status: pilot (2026-09-28), on branch `refactor/authz-evidence-organization`. The operator asked for one +context first. The pilot covers the three Organization administration create commands. The +operator has not approved the rollout to the other contexts. + +Remove this specification when every domain command that needs authority takes evidence that only +its interpreter constructs, and `AGENTS.md#construction-over-trust` names the construction. + +## Goal + +A command that needs authority cannot be called without proof that the authority was checked. +Today the proof is a convention: a check returns `void`, and the call site must remember to run it +before the command. Forgetting it compiles. + +## Evidence (measured on `f433ea9`) + +- 14 exported authorize helpers in `apps/backend/src` and `packages/database/src` return `void` or + a value that any module can construct. They have 123 call sites outside tests. +- Organization administration: `authorizeCreate` in `apps/backend/src/organization/http.ts` + resolves the authority, maps it to `OrganizationActor`, and evaluates the AccessSpec with a + global grant only for an administrator. The service then takes the `OrganizationActor` union. Any + module can build `{ _tag: "OrganizationAdministrator", personId }`, and + `packages/database/src/database.test.ts` does. The adapter therefore decodes the actor again and + runs `authorizeOrganizationActor`, a runtime tag check. +- `OrganizationApiHttpOptions.resolveActor` is wired in `apps/backend/src/router.ts` and never + called. +- Receipts already pass a `ReceiptMutationAuthorization` value into the command, but its + constructors are exported, so it is not evidence yet. + +## Design + +Evidence is a value whose type carries a brand that no exported constructor provides. The +interpreter of the context is the only function that returns it, as a `Result` whose failure is +the existing typed denial. A command takes the narrow evidence type, not the actor union. + +```ts +// packages/domain/src/organization/authority.ts +export interface OrganizationAdministratorEvidence { + readonly [brand]: "OrganizationAdministratorEvidence"; + readonly actor: OrganizationAdministrator; +} + +export const requireOrganizationAdministrator: ( + authority: OrganizationPersonAuthority, +) => Result.Result; + +// packages/domain/src/organization/service.ts +createDepartment(command, administrator: OrganizationAdministratorEvidence) +``` + +The trusted kernel is the interpreter, plus the adapter that reads the authority facts. A caller +that fabricates an `OrganizationPersonAuthority` can still mint evidence; branding the authority +at its reader closes that, and belongs to the rollout. + +What stays: + +- The AccessSpec interpreter in `packages/domain/src/authz`. It decides credentials, concealment, + time and scope, and `docs/model/authority.als` verifies it. Evidence carries its result; it does + not replace it. +- The HTTP contract and the generated SDK. The homepage, OAuth user and service bearers, and the + placements reference depend on them. +- The audit row. `actor_json` keeps the shape `{ _tag: "OrganizationAdministrator", personId }`. + +## Pilot scope + +- Add `OrganizationAdministratorEvidence` and `requireOrganizationAdministrator`. +- `createDepartment`, `createTeam`, and `createFieldOfStudy` take the evidence, in the service + contract, the PostgreSQL adapter, and the HTTP handler. +- Delete `authorizeOrganizationActor`, `decodeOrganizationActor`, `organizationActorFrom`, and the + unused `resolveActor` option with its router wiring. +- The authorization-rules proof observes `requireOrganizationAdministrator`, with the same + observed tags as before. + +## Falsifiers + +1. A call of a create command with an object literal, an `OrganizationMember`, or an + `OrganizationActor` fails to type check (`@ts-expect-error` negative controls in + `packages/domain/src/organization/authority.test.ts`). +2. For every `OrganizationPersonAuthority` that `Arbitrary.schema` generates, evidence exists + exactly when `globalAdministrator` is `Active`, it names the same person, and it agrees with + `mapOrganizationAuthorityToOrganizationActor` (property test). +3. A member who calls the create endpoints still receives `authority.denied`, and nothing is + written (existing backend and PostgreSQL tests). +4. Stryker, run over the pilot module, leaves no surviving mutant in + `requireOrganizationAdministrator`. + +## Rollout, after operator approval + +One context per branch, in order of call sites: the person and anonymous helpers of +`apps/backend/src/http-api/problem.ts` (44), content, admission, recruitment, team applications, +schools, placements certificates, receipts (seal the existing constructors). Each branch deletes the +context's void helper and its runtime re-check in the adapter. + +Stryker is not in the root catalog. The pilot runs it outside the catalog and reports the score. It +joins the catalog, `just`, and hosted Checks only with the rollout, so that no check exists that +nothing runs. diff --git a/packages/database/runtime/authorization-rules-postgres-proof-main.ts b/packages/database/runtime/authorization-rules-postgres-proof-main.ts index 0bdc6a8c..e867326a 100644 --- a/packages/database/runtime/authorization-rules-postgres-proof-main.ts +++ b/packages/database/runtime/authorization-rules-postgres-proof-main.ts @@ -47,7 +47,7 @@ import { OrganizationGlobalAdministratorGrantId, PersonId, SemesterId, - authorizeOrganizationActor, + requireOrganizationAdministrator, mapOrganizationAuthorityToDepartmentActor, mapOrganizationAuthorityToOrganizationActor, mapOrganizationAuthorityToProfileRole, @@ -2058,13 +2058,10 @@ const proveZeroRuleEquivalence = (databaseUrl: Redacted.Redacted) => const organizationAcceptedRulesEmptyActor = mapOrganizationAuthorityToOrganizationActor(administratorProjection); - const organizationAcceptedDirectResult = yield* Effect.result( - authorizeOrganizationActor(organizationAcceptedDirectActor), - ); + const organizationAcceptedDirectResult = requireOrganizationAdministrator(administratorFixture); - const organizationAcceptedRulesEmptyResult = yield* Effect.result( - authorizeOrganizationActor(organizationAcceptedRulesEmptyActor), - ); + const organizationAcceptedRulesEmptyResult = + requireOrganizationAdministrator(administratorProjection); const organizationAcceptedDirect = { actor: organizationAcceptedDirectActor, @@ -2086,13 +2083,9 @@ const proveZeroRuleEquivalence = (databaseUrl: Redacted.Redacted) => const organizationRejectedRulesEmptyActor = mapOrganizationAuthorityToOrganizationActor(memberProjection); - const organizationRejectedDirectResult = yield* Effect.result( - authorizeOrganizationActor(organizationRejectedDirectActor), - ); + const organizationRejectedDirectResult = requireOrganizationAdministrator(memberFixture); - const organizationRejectedRulesEmptyResult = yield* Effect.result( - authorizeOrganizationActor(organizationRejectedRulesEmptyActor), - ); + const organizationRejectedRulesEmptyResult = requireOrganizationAdministrator(memberProjection); const organizationRejectedDirect = { actor: organizationRejectedDirectActor, diff --git a/packages/database/runtime/organization-postgres-proof-main.ts b/packages/database/runtime/organization-postgres-proof-main.ts index 04ff36b6..599249a1 100644 --- a/packages/database/runtime/organization-postgres-proof-main.ts +++ b/packages/database/runtime/organization-postgres-proof-main.ts @@ -9,7 +9,9 @@ import { CreateDepartmentCommandSchema, Organization, OrganizationCommandId, + OrganizationPersonAuthoritySchema, PersonId, + requireOrganizationAdministrator, type CreateDepartmentCommand, } from "@vektorprogrammet/domain/organization"; import { OrganizationLive } from "@vektorprogrammet/database/organization"; @@ -30,10 +32,14 @@ const headMigrationId = Number(databaseSchemaRevision.slice(0, headSeparator)); const headMigrationName = databaseSchemaRevision.slice(headSeparator + 1); -const administrator = { - _tag: "OrganizationAdministrator" as const, +const administratorAuthority = OrganizationPersonAuthoritySchema.make({ personId: PersonId.make("organization-postgres-proof-administrator"), -}; + evaluatedAt: "2026-09-28T12:00:00.000Z", + globalAdministrator: "Active", + memberships: [], + nationalBoardSeats: [], + delegations: [], +}); const replayCommand: CreateDepartmentCommand = CreateDepartmentCommandSchema.make({ commandId: OrganizationCommandId.make(proofCohort.replayCommandId), @@ -110,6 +116,10 @@ const contender = ( SELECT pg_backend_pid() AS pid `; + const administrator = yield* Effect.fromResult( + requireOrganizationAdministrator(administratorAuthority), + ); + yield* Deferred.succeed(ready, undefined); yield* Deferred.await(start); const outcome = yield* Effect.result(organization.createDepartment(command, administrator)); diff --git a/packages/database/src/database.test.ts b/packages/database/src/database.test.ts index ca5fe2b7..c1c14584 100644 --- a/packages/database/src/database.test.ts +++ b/packages/database/src/database.test.ts @@ -40,6 +40,8 @@ import { departmentIdForCommand, Organization, OrganizationCommandId, + OrganizationPersonAuthoritySchema, + requireOrganizationAdministrator, } from "@vektorprogrammet/domain/organization"; import { RecruitmentAssignmentObservationSchema, @@ -4119,40 +4121,18 @@ describe("DatabaseTest", () => { yield* database.migrate; yield* database.migrate; - const administrator = { - _tag: "OrganizationAdministrator" as const, - personId: PersonId.make("organization-pglite-administrator"), - }; - - const member = { - _tag: "OrganizationMember" as const, - personId: PersonId.make("organization-pglite-member"), - }; - - const deniedCommand = { - _tag: "CreateDepartment" as const, - commandId: OrganizationCommandId.make("organization-pglite-denied-department"), - name: "Denied Department", - shortName: "DENY", - email: "denied@example.invalid", - address: null, - city: "Bergen", - latitude: null, - longitude: null, - }; - - const denied = yield* Effect.flip(organization.createDepartment(deniedCommand, member)); - - const deniedRows = yield* database<{ readonly count: string }>` - SELECT ( - (SELECT count(*) FROM organization_departments - WHERE native_creation_command_id = ${deniedCommand.commandId}) - + (SELECT count(*) FROM organization_command_receipts - WHERE command_id = ${deniedCommand.commandId}) - + (SELECT count(*) FROM organization_creation_audit - WHERE command_id = ${deniedCommand.commandId}) - )::text AS count - `; + const administrator = yield* Effect.fromResult( + requireOrganizationAdministrator( + OrganizationPersonAuthoritySchema.make({ + personId: PersonId.make("organization-pglite-administrator"), + evaluatedAt: "2026-09-28T12:00:00.000Z", + globalAdministrator: "Active", + memberships: [], + nationalBoardSeats: [], + delegations: [], + }), + ), + ); const departmentCommand = { _tag: "CreateDepartment" as const, @@ -4424,10 +4404,26 @@ describe("DatabaseTest", () => { const teams = yield* organization.listTeams(); const fields = yield* organization.listFieldOfStudies; + const actors = yield* database<{ + readonly tag: string; + readonly personId: string; + readonly keyCount: string; + }>` + SELECT DISTINCT + actor_json::jsonb ->> '_tag' AS tag, + actor_json::jsonb ->> 'personId' AS "personId", + (SELECT count(*) FROM jsonb_object_keys(actor_json::jsonb))::text AS "keyCount" + FROM organization_command_receipts + WHERE command_id IN ( + ${departmentCommand.commandId}, + ${teamCommand.commandId}, + ${fieldCommand.commandId} + ) + `; + return { schemaRevision: database.schemaRevision, - denied, - deniedRows: Number(deniedRows[0]?.count ?? "-1"), + actors, departmentCreated, departmentReplayed, departmentConflict, @@ -4467,8 +4463,14 @@ describe("DatabaseTest", () => { }); expect(evidence.schemaRevision).toBe(databaseSchemaRevision); - expect(evidence.denied._tag).toBe("OrganizationRoleDenied"); - expect(evidence.deniedRows).toBe(0); + // The audit identity keeps its stored shape: evidence records the administrator actor. + expect(evidence.actors).toEqual([ + { + tag: "OrganizationAdministrator", + personId: "organization-pglite-administrator", + keyCount: "2", + }, + ]); expect(evidence.departmentCreated.committed).toBe(true); expect(evidence.departmentReplayed.committed).toBe(false); expect(evidence.departmentReplayed.observation._tag).toBe("Replayed"); diff --git a/packages/database/src/organization/administration-postgres.ts b/packages/database/src/organization/administration-postgres.ts index 00f1f11a..c31b045a 100644 --- a/packages/database/src/organization/administration-postgres.ts +++ b/packages/database/src/organization/administration-postgres.ts @@ -6,11 +6,9 @@ import { DepartmentCreatedObservationSchema, TeamCreatedObservationSchema, FieldOfStudyCreatedObservationSchema, - authorizeOrganizationActor, decodeCreateDepartmentCommand, decodeCreateFieldOfStudyCommand, decodeCreateTeamCommand, - decodeOrganizationActor, departmentIdForCommand, fieldOfStudyIdForCommand, organizationCommandDigest, @@ -26,6 +24,7 @@ import { type DepartmentCreatedObservation, type FieldOfStudyCreatedObservation, type OrganizationActor, + type OrganizationAdministratorEvidence, type OrganizationCommandId, type OrganizationCreateCommand, type OrganizationCreatedObservation, @@ -544,11 +543,10 @@ const insertFieldOfStudy = ( export const createOrganizationDepartment = ( commandInput: CreateDepartmentCommand, - actorInput: OrganizationActor, + administrator: OrganizationAdministratorEvidence, ): Effect.Effect => Effect.gen(function* () { const command = yield* decodeCreateDepartmentCommand(commandInput); - const actor = yield* decodeOrganizationActor(actorInput); const sql = yield* Database; const digest = organizationCommandDigest(command); @@ -563,7 +561,6 @@ export const createOrganizationDepartment = ( if (receipt !== undefined) return yield* decodeDepartmentReplay(command.commandId, receipt); - yield* authorizeOrganizationActor(actor); const inserted = yield* insertDepartment(sql, command); const observation: DepartmentCreatedObservation = DepartmentCreatedObservationSchema.make( @@ -576,7 +573,7 @@ export const createOrganizationDepartment = ( digest, "Department", inserted.departmentId, - actor, + administrator.actor, observation, ); const selected = yield* readDepartment(sql, inserted.departmentId); @@ -602,11 +599,10 @@ export const createOrganizationDepartment = ( export const createOrganizationTeam = ( commandInput: CreateTeamCommand, - actorInput: OrganizationActor, + administrator: OrganizationAdministratorEvidence, ): Effect.Effect => Effect.gen(function* () { const command = yield* decodeCreateTeamCommand(commandInput); - const actor = yield* decodeOrganizationActor(actorInput); const sql = yield* Database; const digest = organizationCommandDigest(command); @@ -620,7 +616,6 @@ export const createOrganizationTeam = ( ); if (receipt !== undefined) return yield* decodeTeamReplay(command.commandId, receipt); - yield* authorizeOrganizationActor(actor); yield* requireDepartment(sql, command.departmentId); const inserted = yield* insertTeam(sql, command); @@ -635,7 +630,7 @@ export const createOrganizationTeam = ( digest, "Team", inserted.teamId, - actor, + administrator.actor, observation, ); const selected = yield* readTeam(sql, inserted.teamId); @@ -661,11 +656,10 @@ export const createOrganizationTeam = ( export const createOrganizationFieldOfStudy = ( commandInput: CreateFieldOfStudyCommand, - actorInput: OrganizationActor, + administrator: OrganizationAdministratorEvidence, ): Effect.Effect => Effect.gen(function* () { const command = yield* decodeCreateFieldOfStudyCommand(commandInput); - const actor = yield* decodeOrganizationActor(actorInput); const sql = yield* Database; const digest = organizationCommandDigest(command); @@ -680,7 +674,6 @@ export const createOrganizationFieldOfStudy = ( if (receipt !== undefined) return yield* decodeFieldOfStudyReplay(command.commandId, receipt); - yield* authorizeOrganizationActor(actor); if (command.departmentId !== null) yield* requireDepartment(sql, command.departmentId); const inserted = yield* insertFieldOfStudy(sql, command); @@ -697,7 +690,7 @@ export const createOrganizationFieldOfStudy = ( digest, "FieldOfStudy", inserted.fieldOfStudyId, - actor, + administrator.actor, observation, ); const selected = yield* readFieldOfStudy(sql, inserted.fieldOfStudyId); diff --git a/packages/domain/src/organization/AGENTS.md b/packages/domain/src/organization/AGENTS.md index 176064da..e1dd63a0 100644 --- a/packages/domain/src/organization/AGENTS.md +++ b/packages/domain/src/organization/AGENTS.md @@ -80,6 +80,12 @@ Responsibilities: | `@vektorprogrammet/domain/organization` | [index.ts](index.ts) | | `@vektorprogrammet/domain/organization/authority-fixtures` | [authority-fixtures.test-support.ts](authority-fixtures.test-support.ts) | +## Constructs + +The shared constructs defined here. Each name links to its contract; [docs/constructs.md](../../../../docs/constructs.md) indexes them all. + +- [`requireOrganizationAdministrator`](../../../../docs/constructs/authority-evidence.md#requireorganizationadministrator) (authority-evidence): Checks that a resolved authority holds active global administration, and returns the evidence that the Organization administration commands require. + Local invariants, pitfalls, and recipes go below this generated part; `just guides write` keeps them. [//]: # "guide: end" diff --git a/packages/domain/src/organization/administration.test.ts b/packages/domain/src/organization/administration.test.ts index b74fb668..861a1cc5 100644 --- a/packages/domain/src/organization/administration.test.ts +++ b/packages/domain/src/organization/administration.test.ts @@ -2,14 +2,11 @@ import { expect, it } from "@effect/vitest"; import { Effect, Schema } from "effect"; import { CreateDepartmentCommandSchema, - OrganizationMemberSchema, - OrganizationAdministratorSchema, CreateFieldOfStudyCommandSchema, CreateTeamCommandSchema, OrganizationCommandId, } from "./administration-schema.js"; import { - authorizeOrganizationActor, decodeCreateDepartmentCommand, departmentIdForCommand, fieldOfStudyIdForCommand, @@ -17,7 +14,7 @@ import { organizationEntityDigest, teamIdForCommand, } from "./administration.js"; -import { DepartmentId, PersonId } from "./schema.js"; +import { DepartmentId } from "./schema.js"; const departmentCommand = CreateDepartmentCommandSchema.make({ commandId: OrganizationCommandId.make("organization-domain-department-command"), @@ -121,18 +118,3 @@ it("derives stable, kind-separated IDs from the complete SHA-256 digest", () => organizationCommandDigest(departmentCommand), ); }); - -it.effect("allows administrators and returns a typed denial for members", () => - Effect.gen(function* () { - const personId = PersonId.make("organization-domain-actor"); - yield* authorizeOrganizationActor(OrganizationAdministratorSchema.make({ personId })); - - const denied = yield* Effect.flip( - authorizeOrganizationActor(OrganizationMemberSchema.make({ personId })), - ); - - expect(denied._tag).toBe("OrganizationRoleDenied"); - expect(denied.actorPersonId).toBe(personId); - expect(denied.requiredRole).toBe("OrganizationAdministrator"); - }), -); diff --git a/packages/domain/src/organization/administration.ts b/packages/domain/src/organization/administration.ts index 165e0ca8..7d4229d2 100644 --- a/packages/domain/src/organization/administration.ts +++ b/packages/domain/src/organization/administration.ts @@ -1,13 +1,11 @@ -import { flow, Predicate, Effect, Schema } from "effect"; +import { flow, Effect, Schema } from "effect"; import { canonicalJsonBytes, sha256Hex } from "../shared-kernel/index.js"; -import { OrganizationDecodeError, OrganizationRoleDenied } from "./errors.js"; +import { OrganizationDecodeError } from "./errors.js"; import { CreateDepartmentCommandSchema, CreateFieldOfStudyCommandSchema, CreateTeamCommandSchema, - OrganizationActorSchema, OrganizationCreateCommandSchema, - type OrganizationActor, type OrganizationCommandId, type OrganizationCreateCommand, type OrganizationEntityKind, @@ -24,13 +22,6 @@ import { const decodeError = (operation: string, cause: unknown) => new OrganizationDecodeError({ operation, message: String(cause) }); -export const decodeOrganizationActor = flow( - Schema.decodeUnknownEffect(OrganizationActorSchema, { - onExcessProperty: "error", - }), - Effect.mapError((cause) => decodeError("decode organization actor", cause)), -); - export const decodeCreateDepartmentCommand = flow( Schema.decodeUnknownEffect(CreateDepartmentCommandSchema, { onExcessProperty: "error", @@ -106,15 +97,3 @@ export const teamIdForCommand = (commandId: OrganizationCommandId): TeamIdType = export const fieldOfStudyIdForCommand = (commandId: OrganizationCommandId): FieldOfStudyIdType => organizationEntityIdForCommand("FieldOfStudy", commandId); - -export const authorizeOrganizationActor = ( - actor: OrganizationActor, -): Effect.Effect => - Predicate.isTagged(actor, "OrganizationAdministrator") - ? Effect.void - : Effect.fail( - new OrganizationRoleDenied({ - actorPersonId: actor.personId, - requiredRole: "OrganizationAdministrator", - }), - ); diff --git a/packages/domain/src/organization/authority-evidence.test.ts b/packages/domain/src/organization/authority-evidence.test.ts new file mode 100644 index 00000000..f1e739ff --- /dev/null +++ b/packages/domain/src/organization/authority-evidence.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, expectTypeOf, it } from "@effect/vitest"; +import { Effect, Predicate, Result, Schema } from "effect"; +import { Arbitrary } from "effect/unstable/arbitrary"; +import { + type OrganizationActor, + type OrganizationAdministrator, + OrganizationAdministratorSchema, + type OrganizationMember, +} from "./administration-schema.js"; +import { + mapOrganizationAuthorityToOrganizationActor, + type OrganizationAdministratorEvidence, + OrganizationAuthorityBoardSeatSchema, + OrganizationAuthorityMembershipSchema, + OrganizationGlobalAdministratorStatusSchema, + OrganizationPersonAuthoritySchema, + requireOrganizationAdministrator, +} from "./authority.js"; +import { spec0055OrganizationAuthorityFixtures } from "./authority-fixtures.test-support.js"; +import type { OrganizationOperations } from "./service.js"; + +const fixtures = spec0055OrganizationAuthorityFixtures({ + evaluatedAt: "2026-09-28T12:00:00.000Z", + departmentId: "evidence-department", + teamId: "evidence-team", + persons: { + administrator: "evidence-administrator", + leader: "evidence-leader", + inactiveLeader: "evidence-inactive-leader", + member: "evidence-member", + absent: "evidence-absent", + }, + memberships: { + leader: "evidence-leader-membership", + inactiveLeader: "evidence-inactive-leader-membership", + member: "evidence-member-membership", + }, +}); + +/** + * A fixture person and instant with every global-administrator status and generated memberships + * and board seats, leaders and board leaders included. They vary independently of the grant, so a + * check that read them instead of the grant would disagree on some case. Instants are not + * generated: the RFC 3339 filter exhausts the schema arbitrary. + */ +const authority = Arbitrary.map( + Arbitrary.all({ + base: Arbitrary.schema( + Schema.Literals(["administrator", "leader", "inactiveLeader", "member", "absent"]), + ), + globalAdministrator: Arbitrary.schema(OrganizationGlobalAdministratorStatusSchema), + memberships: Arbitrary.schema(Schema.Array(OrganizationAuthorityMembershipSchema)), + nationalBoardSeats: Arbitrary.schema(Schema.Array(OrganizationAuthorityBoardSeatSchema)), + }), + ({ base, globalAdministrator, memberships, nationalBoardSeats }) => + OrganizationPersonAuthoritySchema.make({ + ...fixtures[base], + globalAdministrator, + memberships, + nationalBoardSeats, + }), +); + +const propertyOptions = { arbitrary: { seed: 28092026, runs: 200 } } as const; + +describe("requireOrganizationAdministrator", () => { + it.effect("returns evidence for an active global administrator and a denial for the others", () => + Effect.gen(function* () { + const administrator = yield* Effect.fromResult( + requireOrganizationAdministrator(fixtures.administrator), + ); + + expect(administrator.actor).toEqual( + OrganizationAdministratorSchema.make({ personId: fixtures.administrator.personId }), + ); + + for (const denied of [ + fixtures.leader, + fixtures.inactiveLeader, + fixtures.member, + fixtures.absent, + ]) { + const failure = yield* Effect.flip( + Effect.fromResult(requireOrganizationAdministrator(denied)), + ); + + expect(failure._tag).toBe("OrganizationRoleDenied"); + expect(failure.actorPersonId).toBe(denied.personId); + expect(failure.requiredRole).toBe("OrganizationAdministrator"); + } + }), + ); + + it.prop( + "grants evidence exactly for an active grant, for the same person, in agreement with the actor mapping", + { authority }, + ({ authority }) => { + const result = requireOrganizationAdministrator(authority); + const actor = mapOrganizationAuthorityToOrganizationActor(authority); + + expect(Result.isSuccess(result)).toBe(authority.globalAdministrator === "Active"); + expect(Result.isSuccess(result)).toBe(Predicate.isTagged(actor, "OrganizationAdministrator")); + + if (Result.isSuccess(result)) { + expect(result.success.actor).toEqual(actor); + } else { + expect(result.failure.actorPersonId).toBe(authority.personId); + } + }, + propertyOptions, + ); + + it("is the only way to satisfy an administration command", () => { + type Administrator = Parameters[1]; + + expectTypeOf< + Parameters[1] + >().toEqualTypeOf(); + expectTypeOf< + Parameters[1] + >().toEqualTypeOf(); + expectTypeOf().toExtend(); + + // Negative controls: an actor, a member, or a literal of the evidence's visible shape is not evidence. + expectTypeOf().not.toExtend(); + expectTypeOf().not.toExtend(); + expectTypeOf().not.toExtend(); + expectTypeOf<{ readonly actor: OrganizationAdministrator }>().not.toExtend(); + }); +}); diff --git a/packages/domain/src/organization/authority.ts b/packages/domain/src/organization/authority.ts index 55b77d7a..a9c4b1e1 100644 --- a/packages/domain/src/organization/authority.ts +++ b/packages/domain/src/organization/authority.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import { Result, Schema } from "effect"; import { type AdmissionPeriodActor, AdmissionPeriodActorSchema, @@ -15,8 +15,10 @@ import { compareRfc3339Instants, Rfc3339InstantSchema } from "../time.js"; import { OrganizationMemberSchema, type OrganizationActor, + type OrganizationAdministrator, OrganizationAdministratorSchema, } from "./administration-schema.js"; +import { OrganizationRoleDenied } from "./errors.js"; import { DepartmentId, MembershipId, PersonId, TeamId } from "./schema.js"; const NonEmpty = Schema.String.pipe( @@ -222,6 +224,61 @@ export const mapOrganizationAuthorityToOrganizationActor = ( ? OrganizationAdministratorSchema.make({ personId: authority.personId }) : OrganizationMemberSchema.make({ personId: authority.personId }); +/** Type-only brand. No module exports a value of it, so no module outside this one can build evidence. */ +declare const OrganizationAdministratorEvidenceBrand: unique symbol; + +/** + * Proof that a person held active global administration when their authority was resolved. + * `actor` is the audit identity that the command records. + */ +export interface OrganizationAdministratorEvidence { + readonly [OrganizationAdministratorEvidenceBrand]: "OrganizationAdministratorEvidence"; + readonly actor: OrganizationAdministrator; +} + +/** + * Checks that a resolved authority holds active global administration, and returns the evidence + * that the Organization administration commands require. + * + * @remarks + * It is the only constructor of {@link OrganizationAdministratorEvidence}. An active global + * administrator yields evidence whose actor names the same person; an inactive or absent grant + * yields `OrganizationRoleDenied` for that person. It reads only `globalAdministrator` and + * `personId`: memberships, board seats, and delegations confer no organization administration. + * + * @sideEffects none + * + * @example + * ```ts + * const administrator = yield* Effect.fromResult(requireOrganizationAdministrator(authority)); + * yield* organization.createDepartment(command, administrator); + * ``` + * + * @avoid Passing an `OrganizationActor` to a create command, or checking its tag at the call site: + * any module can build an actor, so the command could not trust it. Resolve the authority and + * require the evidence here. + * + * @construct authority-evidence + */ +export const requireOrganizationAdministrator = ( + /** The person's authority, resolved from current facts inside the command transaction. */ + authority: OrganizationPersonAuthority, +): /** Evidence for an active global administrator, or the typed denial. */ +Result.Result => + authority.globalAdministrator === "Active" + ? Result.succeed( + // SAFETY: the one constructor of the evidence brand; the branch above is the check it proves. + { + actor: OrganizationAdministratorSchema.make({ personId: authority.personId }), + } as OrganizationAdministratorEvidence, + ) + : Result.fail( + new OrganizationRoleDenied({ + actorPersonId: authority.personId, + requiredRole: "OrganizationAdministrator", + }), + ); + export const mapOrganizationAuthorityToProfileRole = ( authority: OrganizationPersonAuthority, ): Decision => { diff --git a/packages/domain/src/organization/service.ts b/packages/domain/src/organization/service.ts index b51ef0e2..4baa09ad 100644 --- a/packages/domain/src/organization/service.ts +++ b/packages/domain/src/organization/service.ts @@ -23,9 +23,12 @@ import type { CreateFieldOfStudyResult, CreateTeamCommand, CreateTeamResult, - OrganizationActor, } from "./administration-schema.js"; -import type { OrganizationAuthorityInstant, OrganizationPersonAuthority } from "./authority.js"; +import type { + OrganizationAdministratorEvidence, + OrganizationAuthorityInstant, + OrganizationPersonAuthority, +} from "./authority.js"; import type { OrganizationDirectoryFacts } from "./directory.js"; import type { SemesterId, TeamInterestRegistration } from "./schema.js"; import type { @@ -152,15 +155,15 @@ export interface OrganizationOperations { readonly createDepartment: ( command: CreateDepartmentCommand, - actor: OrganizationActor, + administrator: OrganizationAdministratorEvidence, ) => Effect.Effect; readonly createTeam: ( command: CreateTeamCommand, - actor: OrganizationActor, + administrator: OrganizationAdministratorEvidence, ) => Effect.Effect; readonly createFieldOfStudy: ( command: CreateFieldOfStudyCommand, - actor: OrganizationActor, + administrator: OrganizationAdministratorEvidence, ) => Effect.Effect; readonly readMembership: ( membershipId: MembershipId, diff --git a/tools/conventions/src/constructs.ts b/tools/conventions/src/constructs.ts index ed8b733a..b31f8892 100644 --- a/tools/conventions/src/constructs.ts +++ b/tools/conventions/src/constructs.ts @@ -41,6 +41,8 @@ import type { Repository } from "./repository.js"; export const constructCategories = { "http-transport": "Reads native HTTP requests and writes their representations: bounded JSON, preconditions, idempotency keys, entity tags, and cache headers.", + "authority-evidence": + "Checks a resolved authority and returns the evidence that a command which needs that authority takes; nothing else constructs it.", "http-problem": "Answers a native HTTP request with a declared problem: failure mapping, credential classification, authorization, and decoding.", "sql-lock": "Transaction-scoped PostgreSQL advisory locks under registered keys.", From 55877c8c243f862d33eccf8afeb9f7f3605d8ebc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 00:26:44 +0000 Subject: [PATCH 2/9] test(organization): record evidence pilot results and mutation findings Flatten the evidence tests: Stryker 10 with Vitest 5 misses failures of tests nested in describe, and killed 18 of 18 mutants once they were top level. The authorization evidence spec records the falsifier results, and STATE.md records the helpers still trusted by convention. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- STATE.md | 2 + docs/specs/authz-evidence.md | 45 ++++++++---- .../organization/authority-evidence.test.ts | 70 +++++++++---------- 3 files changed, 70 insertions(+), 47 deletions(-) diff --git a/STATE.md b/STATE.md index 4b334309..e4798e65 100644 --- a/STATE.md +++ b/STATE.md @@ -110,6 +110,8 @@ Fix an instance when a change touches it (see [AGENTS.md](AGENTS.md#construction - `apps/homepage/src/lib/public-application.ts` lists its problem codes by hand and omits `header.malformed`. The homepage problem mappers (`mapPublicApplicationError`, `publicTeamApplicationPageFailure`, `failedPublicTeamApplication`) still accept a plain problem-shaped object besides the SDK's `Problem`; the dashboard reads problems only through `nativeProblemFrom`. - Instants: domain fields still use `Rfc3339InstantSchema`, not `Instant` (`packages/domain/src/time.ts`). +- Authorization is trusted by convention: 13 authorize helpers return `void` or a value any module can construct, and a command runs whether or not its caller called one. + The Organization create commands take evidence that only `requireOrganizationAdministrator` constructs; the other contexts await the rollout in [authorization evidence](docs/specs/authz-evidence.md). The authority instant (M2), per-slice cutovers (M3), and deletion of the string helpers such as `compareRfc3339Instants` (M4) remain. - Receipt keyset cursors carry microsecond text (`packages/database/src/receipt/cursor.ts`). They stay exact because storage is millisecond; M3 moves them to `Instant`. - `packages/database/runtime/schema-boundary-postgres-proof-main.ts` (`proof:schema-boundary-postgres`, run by no `just` set) failed at `65e98276`, before and after its move to an Effect program, with "New interview conduct requires an explicit recommendation": its seed inserts an interview conduct without the recommendation that migration 0037 requires of every new conduct. diff --git a/docs/specs/authz-evidence.md b/docs/specs/authz-evidence.md index 7e4fb97b..cee4f4f1 100644 --- a/docs/specs/authz-evidence.md +++ b/docs/specs/authz-evidence.md @@ -72,18 +72,39 @@ What stays: - The authorization-rules proof observes `requireOrganizationAdministrator`, with the same observed tags as before. -## Falsifiers - -1. A call of a create command with an object literal, an `OrganizationMember`, or an - `OrganizationActor` fails to type check (`@ts-expect-error` negative controls in - `packages/domain/src/organization/authority.test.ts`). -2. For every `OrganizationPersonAuthority` that `Arbitrary.schema` generates, evidence exists - exactly when `globalAdministrator` is `Active`, it names the same person, and it agrees with - `mapOrganizationAuthorityToOrganizationActor` (property test). -3. A member who calls the create endpoints still receives `authority.denied`, and nothing is - written (existing backend and PostgreSQL tests). -4. Stryker, run over the pilot module, leaves no surviving mutant in - `requireOrganizationAdministrator`. +## Falsifiers and pilot results + +Measured on the pilot commit, with PostgreSQL 18 and `effect` 4.0.0-rc.116. + +1. A create command rejects an `OrganizationActor`, an `OrganizationAdministrator`, an + `OrganizationMember`, and a literal `{ actor }` at the type level: `expectTypeOf(...).not.toExtend` + negative controls in `packages/domain/src/organization/authority-evidence.test.ts`, which + `check-types` compiles. The compiler also found a forged administrator literal in + `packages/database/runtime/organization-postgres-proof-main.ts`, now minted from an authority. +2. Property test (`it.prop`, seed 28092026, 200 runs): evidence exists exactly when + `globalAdministrator` is `Active`, for the same person, in agreement with + `mapOrganizationAuthorityToOrganizationActor`, while memberships and board seats vary through + `Arbitrary.schema`. `Arbitrary.schema(OrganizationPersonAuthoritySchema)` itself exhausts on the + RFC 3339 instant filter (0 runs, 501 discards), so the instant and delegations come from the + fixtures. +3. Backend organization tests (14) and database organization tests (45) pass on PostgreSQL. The + PGlite test now asserts the stored `actor_json` shape. The authorization-rules proof output is + identical before and after, except process ids and timestamps. +4. Stryker 10.0.0, `coverageAnalysis: off`, over `requireOrganizationAdministrator` and its + neighbours in `authority.ts`: 18 of 18 mutants killed. +5. Hand-applied mutants of `authorizeCreate`: granting no scope fails 2 backend tests. Always + granting the global scope passes every test, and is equivalent: a member is still answered + `authority.denied`, now by the missing evidence instead of the AccessSpec. + +Stryker 10 with Vitest 5 does not see failures of tests nested in `describe`: the same assertion +killed every mutant at top level and none inside a `describe`. Its score overstates survivors in +such suites. The pilot test file is flat for this reason. Check the runner version for this before +Stryker joins the checks. + +Invocation, from `packages/domain`, with Stryker installed outside the catalog and its `vitest` +resolved to the repository's copy: a Vitest config that includes `src/organization/**/*.test.ts`, +and a Stryker config with `testRunner: "vitest"`, `inPlace: true`, `concurrency: 1`, +`coverageAnalysis: "off"`, and `mutate: ["src/organization/authority.ts:"]`. ## Rollout, after operator approval diff --git a/packages/domain/src/organization/authority-evidence.test.ts b/packages/domain/src/organization/authority-evidence.test.ts index f1e739ff..1d8c024b 100644 --- a/packages/domain/src/organization/authority-evidence.test.ts +++ b/packages/domain/src/organization/authority-evidence.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, expectTypeOf, it } from "@effect/vitest"; +import { expect, expectTypeOf, it } from "@effect/vitest"; import { Effect, Predicate, Result, Schema } from "effect"; import { Arbitrary } from "effect/unstable/arbitrary"; import { @@ -63,8 +63,9 @@ const authority = Arbitrary.map( const propertyOptions = { arbitrary: { seed: 28092026, runs: 200 } } as const; -describe("requireOrganizationAdministrator", () => { - it.effect("returns evidence for an active global administrator and a denial for the others", () => +it.effect( + "requireOrganizationAdministrator returns evidence for an active global administrator and a denial for the others", + () => Effect.gen(function* () { const administrator = yield* Effect.fromResult( requireOrganizationAdministrator(fixtures.administrator), @@ -89,42 +90,41 @@ describe("requireOrganizationAdministrator", () => { expect(failure.requiredRole).toBe("OrganizationAdministrator"); } }), - ); +); - it.prop( - "grants evidence exactly for an active grant, for the same person, in agreement with the actor mapping", - { authority }, - ({ authority }) => { - const result = requireOrganizationAdministrator(authority); - const actor = mapOrganizationAuthorityToOrganizationActor(authority); +it.prop( + "requireOrganizationAdministrator grants evidence exactly for an active grant, for the same person, in agreement with the actor mapping", + { authority }, + ({ authority }) => { + const result = requireOrganizationAdministrator(authority); + const actor = mapOrganizationAuthorityToOrganizationActor(authority); - expect(Result.isSuccess(result)).toBe(authority.globalAdministrator === "Active"); - expect(Result.isSuccess(result)).toBe(Predicate.isTagged(actor, "OrganizationAdministrator")); + expect(Result.isSuccess(result)).toBe(authority.globalAdministrator === "Active"); + expect(Result.isSuccess(result)).toBe(Predicate.isTagged(actor, "OrganizationAdministrator")); - if (Result.isSuccess(result)) { - expect(result.success.actor).toEqual(actor); - } else { - expect(result.failure.actorPersonId).toBe(authority.personId); - } - }, - propertyOptions, - ); + if (Result.isSuccess(result)) { + expect(result.success.actor).toEqual(actor); + } else { + expect(result.failure.actorPersonId).toBe(authority.personId); + } + }, + propertyOptions, +); - it("is the only way to satisfy an administration command", () => { - type Administrator = Parameters[1]; +it("requireOrganizationAdministrator evidence is the only way to satisfy an administration command", () => { + type Administrator = Parameters[1]; - expectTypeOf< - Parameters[1] - >().toEqualTypeOf(); - expectTypeOf< - Parameters[1] - >().toEqualTypeOf(); - expectTypeOf().toExtend(); + expectTypeOf< + Parameters[1] + >().toEqualTypeOf(); + expectTypeOf< + Parameters[1] + >().toEqualTypeOf(); + expectTypeOf().toExtend(); - // Negative controls: an actor, a member, or a literal of the evidence's visible shape is not evidence. - expectTypeOf().not.toExtend(); - expectTypeOf().not.toExtend(); - expectTypeOf().not.toExtend(); - expectTypeOf<{ readonly actor: OrganizationAdministrator }>().not.toExtend(); - }); + // Negative controls: an actor, a member, or a literal of the evidence's visible shape is not evidence. + expectTypeOf().not.toExtend(); + expectTypeOf().not.toExtend(); + expectTypeOf().not.toExtend(); + expectTypeOf<{ readonly actor: OrganizationAdministrator }>().not.toExtend(); }); From 6ee807dbbf87e0b9259031e81569159b6185dd79 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:18:51 +0000 Subject: [PATCH 3/9] refactor(authz): require department reach evidence for unguarded commands Add requireDepartmentReach, the only constructor of DepartmentReach, which proves a capability over one department. Three commands whose only barrier was a check at their HTTP caller now take it: - commandOnboarding takes the coordinator's admissions.outcomes reach and reads its department and audited actor from it. - recordAdmissionOutcome takes the decider's reach and denies evidence for another department on the locked application, a check it lacked. - Placements.execute takes a coordinator's placements.coordinate reach for board and coverage-board changes; own affiliation and coverage still act as the person. Property tests show the evidence agrees with reaches, with department administration, with canManagePlacements, and with the outcome Decide permission, so the replaced handler checks keep their answers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/backend/src/admission/outcome-http.ts | 11 +- .../backend/src/onboarding/claim.http.test.ts | 10 +- apps/backend/src/onboarding/http.ts | 13 +- apps/backend/src/placements/http.ts | 148 ++++++++---- docs/constructs.md | 1 + docs/constructs/authority-evidence.md | 44 ++++ .../database/src/admissions/outcome.test.ts | 38 ++- packages/database/src/admissions/outcome.ts | 9 +- packages/database/src/onboarding.test.ts | 19 +- packages/database/src/onboarding/postgres.ts | 17 +- .../database/src/placements/service.test.ts | 18 +- packages/database/src/placements/service.ts | 13 +- packages/domain/src/admissions/service.ts | 5 +- packages/domain/src/authz/AGENTS.md | 6 + .../domain/src/authz/reach-evidence.test.ts | 228 ++++++++++++++++++ packages/domain/src/authz/reach.ts | 67 ++++- .../authority-fixtures.test-support.ts | 33 +++ packages/domain/src/placements/service.ts | 27 ++- .../verification/onboarding-delivery.test.ts | 9 +- 19 files changed, 627 insertions(+), 89 deletions(-) create mode 100644 packages/domain/src/authz/reach-evidence.test.ts diff --git a/apps/backend/src/admission/outcome-http.ts b/apps/backend/src/admission/outcome-http.ts index f99762cf..301063ab 100644 --- a/apps/backend/src/admission/outcome-http.ts +++ b/apps/backend/src/admission/outcome-http.ts @@ -10,7 +10,7 @@ import { type AdmissionOutcomeEntry, type AdmissionOutcomeOperationFailure, } from "@vektorprogrammet/domain/admissions"; -import { Scope } from "@vektorprogrammet/domain/authz"; +import { requireDepartmentReach, Scope } from "@vektorprogrammet/domain/authz"; import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; import type { OrganizationPersistenceError } from "@vektorprogrammet/domain/organization"; import { @@ -293,11 +293,16 @@ export const AdmissionOutcomesApiHandlers = (input: { now?: () => string }) => { const auth = yield* personAuthority(request); + // Deciding needs the department's reach; the command takes it as evidence. + const decider = yield* Effect.fromResult( + requireDepartmentReach(auth.authority, "admissions.outcomes", selected.departmentId), + ).pipe(Effect.mapError(() => Problem.make("authority.denied"))); + yield* authorize( request, RecordAdmissionOutcomeEndpoint, selected.departmentId, - true, + false, auth, ); @@ -322,7 +327,7 @@ export const AdmissionOutcomesApiHandlers = (input: { now?: () => string }) => { { applicationId, command, - actor: auth.authority.personId, + decider, now: auth.authorizationInstant, }, (current) => requireCurrentETag(outcomeResource(current).etag, ifMatch), diff --git a/apps/backend/src/onboarding/claim.http.test.ts b/apps/backend/src/onboarding/claim.http.test.ts index 79e08e28..598c7331 100644 --- a/apps/backend/src/onboarding/claim.http.test.ts +++ b/apps/backend/src/onboarding/claim.http.test.ts @@ -1,3 +1,4 @@ +import { administratorDepartmentReach } from "@vektorprogrammet/domain/organization/authority-fixtures"; import { createHash } from "node:crypto"; import { Database, IdentitySnapshot, OAuthCredentialAuthority } from "@vektorprogrammet/database"; import { commandOnboarding } from "@vektorprogrammet/database/onboarding"; @@ -10,7 +11,7 @@ import { } from "@vektorprogrammet/domain/authz"; import { IdentityActor, IdentitySessionNotFound } from "@vektorprogrammet/domain/identity"; import { OnboardingClaim, OnboardingClaimResult } from "@vektorprogrammet/domain/onboarding"; -import { DepartmentId, Organization, PersonId } from "@vektorprogrammet/domain/organization"; +import { Organization, PersonId } from "@vektorprogrammet/domain/organization"; import { NativeProblem } from "@vektorprogrammet/http-api"; import { DateTime, Effect, Layer, Schema } from "effect"; import { describe, expect, it } from "@effect/vitest"; @@ -78,12 +79,15 @@ const seed = Database.use((sql) => `; yield* sql.withTransaction( commandOnboarding({ - departmentId: DepartmentId.make("claim-department"), + coordinator: administratorDepartmentReach( + "claim-issuer", + "admissions.outcomes", + "claim-department", + ), command: { applicationId: PublicApplicationIdSchema.make(`claim-application-${applicant}`), action: "Issue", }, - actor: PersonId.make("claim-issuer"), now: issuedAt, invitationId: `claim-invitation-${applicant}`, token: tokens[applicant], diff --git a/apps/backend/src/onboarding/http.ts b/apps/backend/src/onboarding/http.ts index 96d36303..6668e4e3 100644 --- a/apps/backend/src/onboarding/http.ts +++ b/apps/backend/src/onboarding/http.ts @@ -1,7 +1,6 @@ import { credentialMatchesAccessSpec, - reaches, - ReachTarget, + requireDepartmentReach, Scope, } from "@vektorprogrammet/domain/authz"; import { Database } from "@vektorprogrammet/database"; @@ -184,8 +183,9 @@ const authorize = ( Effect.gen(function* () { const auth = yield* resolveRequestPersonAuthorityInTransaction(request, { now }); - if (!reaches(auth.authority, "admissions.outcomes", ReachTarget.Department({ departmentId }))) - return yield* Problem.make("authority.denied"); + const coordinator = yield* Effect.fromResult( + requireDepartmentReach(auth.authority, "admissions.outcomes", departmentId), + ).pipe(Effect.mapError(() => Problem.make("authority.denied"))); yield* authorizePerson( { @@ -208,7 +208,7 @@ const authorize = ( presentation, ); - return auth; + return { ...auth, coordinator }; }); /** @@ -302,9 +302,8 @@ export const OnboardingApiHandlers = (input: { const current = resource(yield* readOnboardingBoard(scope.departmentId)); yield* requireCurrentETag(current.etag, ifMatch); yield* commandOnboarding({ - departmentId: scope.departmentId, + coordinator: auth.coordinator, command: selected, - actor: auth.authority.personId, now: auth.authorizationInstant, invitationId: "onboarding-" + identity.identitySha256, token, diff --git a/apps/backend/src/placements/http.ts b/apps/backend/src/placements/http.ts index c11fcb6a..509b8d10 100644 --- a/apps/backend/src/placements/http.ts +++ b/apps/backend/src/placements/http.ts @@ -1,6 +1,10 @@ import { Database } from "@vektorprogrammet/database"; import type { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; -import { DomainId, Scope } from "@vektorprogrammet/domain/authz"; +import { DomainId, requireDepartmentReach, Scope } from "@vektorprogrammet/domain/authz"; +import type { + DepartmentId, + OrganizationPersonAuthority, +} from "@vektorprogrammet/domain/organization"; import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; import { AffiliationScope, @@ -11,11 +15,11 @@ import { Placements, PlacementScope, PlacementScopes, - canManagePlacements, type CoverageCommand as CoverageCommandType, type OwnAffiliationCommand as OwnAffiliationCommandType, type OwnCoverageCommand as OwnCoverageCommandType, type PlacementCommand as PlacementCommandType, + type PlacementExecution, type PlacementOperationFailure, } from "@vektorprogrammet/domain/placements"; import { @@ -44,6 +48,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; import { type OrganizationResolutionError, resolveRequestPersonAuthorityInTransaction, + type TransactionPersonAuthority, } from "../authority.js"; import { authorizePerson, @@ -155,6 +160,48 @@ type Endpoint = | typeof CommandCoverageBoardEndpoint | typeof ReadPlacementDraftEndpoint; +/** The coordinator's reach over the department, which a board or coverage-board change requires. */ +const requireCoordinator = ( + authority: OrganizationPersonAuthority, + departmentId: DepartmentId | null, +) => + departmentId === null + ? Effect.fail(Problem.make("authority.denied")) + : Effect.fromResult( + requireDepartmentReach(authority, "placements.coordinate", departmentId), + ).pipe(Effect.mapError(() => Problem.make("authority.denied"))); + +/** Evaluates the endpoint's AccessSpec for the resolved person in the department's scope. */ +const evaluateAccess = ( + request: Request, + endpoint: Endpoint, + departmentId: PlacementScope["departmentId"] | null, + auth: TransactionPersonAuthority, +) => + authorizePerson( + { + spec: Option.getOrThrow(reflectAccessSpec(endpoint)), + credential: auth.credential, + personId: auth.authority.personId, + resolution: { + selection: "ExactlyOne", + contexts: [ + genericContext({ + domainId: "organization", + departmentId: departmentId ?? undefined, + authorityVersion: auth.authorizationInstant, + }), + ], + }, + grantScopes: + departmentId === null + ? [Scope.Domain({ domainId: DomainId.make("organization") })] + : [Scope.Department({ departmentId })], + now: auth.authorizationInstant, + }, + personPresentation(request), + ); + const authorize = ( request: Request, endpoint: Endpoint, @@ -165,37 +212,28 @@ const authorize = ( Effect.gen(function* () { const auth = yield* resolveRequestPersonAuthorityInTransaction(request, { now }); - if (manage && (departmentId === null || !canManagePlacements(auth.authority, departmentId))) { - return yield* Problem.make("authority.denied"); - } + if (manage) yield* requireCoordinator(auth.authority, departmentId); - yield* authorizePerson( - { - spec: Option.getOrThrow(reflectAccessSpec(endpoint)), - credential: auth.credential, - personId: auth.authority.personId, - resolution: { - selection: "ExactlyOne", - contexts: [ - genericContext({ - domainId: "organization", - departmentId: departmentId ?? undefined, - authorityVersion: auth.authorizationInstant, - }), - ], - }, - grantScopes: - departmentId === null - ? [Scope.Domain({ domainId: DomainId.make("organization") })] - : [Scope.Department({ departmentId })], - now: auth.authorizationInstant, - }, - personPresentation(request), - ); + yield* evaluateAccess(request, endpoint, departmentId, auth); return auth; }); +/** Resolves the person, requires the coordinator's reach, and returns its evidence. */ +const authorizeCoordinator = ( + request: Request, + endpoint: Endpoint, + departmentId: PlacementScope["departmentId"], + now?: () => string, +) => + Effect.gen(function* () { + const auth = yield* resolveRequestPersonAuthorityInTransaction(request, { now }); + const coordinator = yield* requireCoordinator(auth.authority, departmentId); + yield* evaluateAccess(request, endpoint, departmentId, auth); + + return { auth, coordinator }; + }); + type MutationSelection = | { readonly mode: "affiliation"; @@ -413,13 +451,43 @@ export const PlacementsApiHandlers = (input: { now?: () => string }) => { // Domain and credential failures are mapped after the executor, whose retry reads their causes. const outcome = yield* executeNativeHttpCommandPostgres( Effect.gen(function* () { - const auth = yield* authorize( - request, - selected.endpoint, - selected.scope.departmentId, - selected.manage, - input.now, - ); + // A board or coverage-board change runs on the coordinator's evidence; a person's own + // affiliation or coverage runs as that person. + const { auth, execution } = + selected.mode === "board" || selected.mode === "coverage" + ? yield* authorizeCoordinator( + request, + selected.endpoint, + selected.scope.departmentId, + input.now, + ).pipe( + Effect.map(({ auth, coordinator }) => ({ + auth, + execution: (commandId: string): PlacementExecution => ({ + mutation: selected, + coordinator, + now: auth.authorizationInstant, + commandId, + }), + })), + ) + : yield* authorize( + request, + selected.endpoint, + selected.scope.departmentId, + false, + input.now, + ).pipe( + Effect.map((auth) => ({ + auth, + execution: (commandId: string): PlacementExecution => ({ + mutation: selected, + actor: auth.authority.personId, + now: auth.authorizationInstant, + commandId, + }), + })), + ); const identity = yield* httpIdentity({ credentialSubject: `Person:${auth.authority.personId}`, @@ -438,14 +506,8 @@ export const PlacementsApiHandlers = (input: { now?: () => string }) => { const placements = yield* Placements; const changed = resource( - yield* placements.execute( - { - mutation: selected, - actor: auth.authority.personId, - now: auth.authorizationInstant, - commandId: identity.identitySha256, - }, - (current) => requireCurrentETag(resource(current).etag, ifMatch), + yield* placements.execute(execution(identity.identitySha256), (current) => + requireCurrentETag(resource(current).etag, ifMatch), ), ); diff --git a/docs/constructs.md b/docs/constructs.md index abcb8cbb..ce7b4080 100644 --- a/docs/constructs.md +++ b/docs/constructs.md @@ -18,6 +18,7 @@ Tag a construct only when at least 2 modules outside its own module and the test - [`normalizeTarget`](constructs/http-transport.md#normalizetarget): Fills a route template with its encoded identities; a missing identity is a malformed request. - [`deriveHttpIdentity`](constructs/http-transport.md#derivehttpidentity): Derives the private storage digest and domain command ID from the identity tuple. - [authority-evidence](constructs/authority-evidence.md): Checks a resolved authority and returns the evidence that a command which needs that authority takes; nothing else constructs it. + - [`requireDepartmentReach`](constructs/authority-evidence.md#requiredepartmentreach): Checks that a resolved authority reaches one department with a capability, and returns the evidence that a department-scoped command requires. - [`requireOrganizationAdministrator`](constructs/authority-evidence.md#requireorganizationadministrator): Checks that a resolved authority holds active global administration, and returns the evidence that the Organization administration commands require. - [http-problem](constructs/http-problem.md): Answers a native HTTP request with a declared problem: failure mapping, credential classification, authorization, and decoding. - [`authorizeAdmissionPerson`](constructs/http-problem.md#authorizeadmissionperson): Evaluates one admission person AccessSpec. diff --git a/docs/constructs/authority-evidence.md b/docs/constructs/authority-evidence.md index 32e9b50f..c1c6df63 100644 --- a/docs/constructs/authority-evidence.md +++ b/docs/constructs/authority-evidence.md @@ -4,6 +4,50 @@ Checks a resolved authority and returns the evidence that a command which needs that authority takes; nothing else constructs it. The [index](../constructs.md) lists every category. +## `requireDepartmentReach` + +Checks that a resolved authority reaches one department with a capability, and returns the evidence that a department-scoped command requires. + +```ts +requireDepartmentReach( + authority: OrganizationPersonAuthority, + capability: C, + departmentId: DepartmentId +): Result.Result, DepartmentReachDenied> +``` + +- Inputs: + - `authority: OrganizationPersonAuthority` + - `capability: C` + - `departmentId: DepartmentId` +- Output: `Result.Result, DepartmentReachDenied>` +- Errors: `DepartmentReachDenied` +- Requirements: none +- Side effects: none +- Source: [packages/domain/src/authz/reach.ts:194](../../packages/domain/src/authz/reach.ts#L194) + +**How it works** + +It is the only constructor of `DepartmentReach` and decides exactly as `reaches` +with a department target: a board leadership of the independent department, a national board +leadership, a delegation over the department or the organization, or an active global +administrator where the capability admits one. A team leadership never reaches a department. + +**Use** + +```ts +const coordinator = yield* Effect.fromResult( + requireDepartmentReach(authority, "placements.coordinate", departmentId), +); +yield* placements.execute({ mutation, coordinator, now, commandId }); +``` + +**Avoid** + +Checking `reaches` at the call site and passing a department and a person to the +command: the command then trusts that some caller checked, for that department. Require the +evidence, and take the department from it. + ## `requireOrganizationAdministrator` Checks that a resolved authority holds active global administration, and returns the evidence that the Organization administration commands require. diff --git a/packages/database/src/admissions/outcome.test.ts b/packages/database/src/admissions/outcome.test.ts index db959c5a..cde5edb4 100644 --- a/packages/database/src/admissions/outcome.test.ts +++ b/packages/database/src/admissions/outcome.test.ts @@ -1,3 +1,4 @@ +import { administratorDepartmentReach } from "@vektorprogrammet/domain/organization/authority-fixtures"; import { expect, layer } from "@effect/vitest"; import { Effect, Exit, Layer, Predicate } from "effect"; import { PublicApplicationIdSchema } from "@vektorprogrammet/domain/application"; @@ -20,10 +21,12 @@ const applicationId = PublicApplicationIdSchema.make("outcome-application"); const recruiter = PersonId.make("outcome-recruiter"); +const decider = administratorDepartmentReach(recruiter, "admissions.outcomes", scope.departmentId); + const record = (outcome: AdmissionOutcome, now = "2026-06-01T10:00:00.000Z") => Admissions.use((service) => service.recordAdmissionOutcome( - { applicationId, command: { outcome }, actor: recruiter, now }, + { applicationId, command: { outcome }, decider, now }, () => Effect.void, ), ); @@ -84,7 +87,7 @@ layer(suiteSeed.pipe(Layer.provideMerge(suiteLayer)), { { applicationId, command: { outcome: "Substitute" }, - actor: recruiter, + decider, now: "2026-06-01T10:00:00.000Z", }, (current) => Effect.fail({ code: "precondition.failed", current }), @@ -101,6 +104,37 @@ layer(suiteSeed.pipe(Layer.provideMerge(suiteLayer)), { }), ); + it.effect("refuses evidence for another department and writes nothing", () => + Effect.gen(function* () { + const result = yield* observeRolledBack( + Effect.gen(function* () { + const failure = yield* Effect.flip( + Admissions.use((service) => + service.recordAdmissionOutcome( + { + applicationId, + command: { outcome: "Admitted" }, + decider: administratorDepartmentReach( + recruiter, + "admissions.outcomes", + "outcome-other-department", + ), + now: "2026-06-01T10:00:00.000Z", + }, + () => Effect.void, + ), + ), + ); + + return { failure, after: yield* readEntry }; + }), + ); + + expect(result.failure).toMatchObject({ code: "authority.denied", status: 403 }); + expect(result.after).toMatchObject({ outcome: null, revision: 0 }); + }), + ); + it.effect("leaves no outcome when the caller cannot finish its receipt", () => Effect.gen(function* () { const failure = yield* Effect.flip( diff --git a/packages/database/src/admissions/outcome.ts b/packages/database/src/admissions/outcome.ts index acf536ac..14cbbc49 100644 --- a/packages/database/src/admissions/outcome.ts +++ b/packages/database/src/admissions/outcome.ts @@ -161,7 +161,7 @@ export const admissionOutcomeOperations = ( }), ), readAdmissionOutcome: (applicationId) => run(readEntry(applicationId)), - recordAdmissionOutcome: ({ applicationId, command, actor, now }, checkPrecondition) => + recordAdmissionOutcome: ({ applicationId, command, decider, now }, checkPrecondition) => Effect.gen(function* () { yield* run( Database.use( @@ -170,6 +170,11 @@ export const admissionOutcomeOperations = ( ), ); const current = yield* run(readEntry(applicationId)); + + // The evidence decides only in its own department, checked on the locked application. + if (current.departmentId !== decider.departmentId) + return yield* new AdmissionOutcomeFailure({ code: "authority.denied", status: 403 }); + yield* checkPrecondition(current); if (current.outcome === command.outcome) return current; @@ -179,7 +184,7 @@ export const admissionOutcomeOperations = ( yield* Database.use( (sql) => sql`INSERT INTO public.admission_application_outcomes (application_id, revision, outcome, decided_by_person_id, decided_at) - VALUES (${applicationId}, ${current.revision + 1}, ${command.outcome}, ${actor}, ${now})`, + VALUES (${applicationId}, ${current.revision + 1}, ${command.outcome}, ${decider.personId}, ${now})`, ); return yield* readEntry(applicationId); diff --git a/packages/database/src/onboarding.test.ts b/packages/database/src/onboarding.test.ts index 01ce3742..bfee3ffc 100644 --- a/packages/database/src/onboarding.test.ts +++ b/packages/database/src/onboarding.test.ts @@ -1,3 +1,4 @@ +import { administratorDepartmentReach } from "@vektorprogrammet/domain/organization/authority-fixtures"; import { expect, layer } from "@effect/vitest"; import { Database } from "./service.js"; import { DepartmentId, PersonId } from "@vektorprogrammet/domain/organization"; @@ -16,6 +17,8 @@ const dept = DepartmentId.make("onboarding-dept"); const actor = PersonId.make("onboarding-leader"); +const coordinator = administratorDepartmentReach("onboarding-leader", "admissions.outcomes", dept); + const currentInstant = Effect.map(DateTime.now, DateTime.formatIso); const setup = (now: string) => @@ -38,12 +41,11 @@ const issue = (i: number, now: string) => Database.use((sql) => sql.withTransaction( commandOnboarding({ - departmentId: dept, + coordinator, command: { applicationId: PublicApplicationIdSchema.make("onboard-app-" + i), action: "Issue", }, - actor, now, invitationId: "onboard-invite-" + i, token: "onboard_" + String(i).repeat(64), @@ -151,12 +153,11 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "15 seconds" })( yield* Database.use((sql) => sql.withTransaction( commandOnboarding({ - departmentId: dept, + coordinator, command: { applicationId: PublicApplicationIdSchema.make("onboard-app-6"), action: "Issue", }, - actor, now, invitationId: "aaa-reissued", token: "onboard_" + "7".repeat(64), @@ -194,21 +195,23 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "15 seconds" })( yield* sql`INSERT INTO admission_period_fields_of_study VALUES('onboarding-other-fos',${other},'Other Math',true)`; yield* sql`INSERT INTO admission_applications(application_id,applicant_id,admission_period_id,department_id,field_of_study_id,year_of_study,submitted_at) VALUES(${applicationId},'onboard-applicant-3','onboarding-other-period',${other},'onboarding-other-fos',2,${now})`; yield* commandOnboarding({ - departmentId: other, + coordinator: administratorDepartmentReach( + "onboarding-leader", + "admissions.outcomes", + "onboarding-other", + ), command: { applicationId, action: "Issue" }, - actor, now, invitationId: "other-invite", token: "onboard_" + "9".repeat(64), digest: "9".repeat(64), }); yield* commandOnboarding({ - departmentId: dept, + coordinator, command: { applicationId: PublicApplicationIdSchema.make("onboard-app-3"), action: "Revoke", }, - actor, now, invitationId: "original-revoke", token: "unused", diff --git a/packages/database/src/onboarding/postgres.ts b/packages/database/src/onboarding/postgres.ts index 87c120f1..8c80c800 100644 --- a/packages/database/src/onboarding/postgres.ts +++ b/packages/database/src/onboarding/postgres.ts @@ -1,6 +1,7 @@ import { DateTime, Effect } from "effect"; import { AdvisoryLockKey, lockAdvisory } from "../advisory-lock.js"; import { Database } from "../service.js"; +import type { DepartmentReach } from "@vektorprogrammet/domain/authz"; import { DepartmentId, PersonId } from "@vektorprogrammet/domain/organization"; import { OnboardingFailure, @@ -40,10 +41,14 @@ export const readOnboardingBoard = (departmentId: DepartmentId) => }), ); +/** + * Issues, revokes, or retries one onboarding invitation in the coordinator's department. The + * department and the audited actor come from the evidence, so the command acts only where the + * coordinator holds `admissions.outcomes`. + */ export const commandOnboarding = (input: { - departmentId: DepartmentId; + coordinator: DepartmentReach<"admissions.outcomes">; command: OnboardingCommand; - actor: PersonId; now: string; invitationId: string; token: string; @@ -53,7 +58,7 @@ export const commandOnboarding = (input: { Effect.gen(function* () { const { applicantId } = yield* onboardingApplication( input.command.applicationId, - input.departmentId, + input.coordinator.departmentId, ); yield* lockOnboardingApplicant(applicantId); @@ -71,7 +76,7 @@ export const commandOnboarding = (input: { for (const row of old) { yield* sql`UPDATE public.applicant_account_delivery SET state='Cancelled',secret=NULL,envelope=NULL,claim_id=NULL,claimed_at=NULL WHERE invitation_id=${row.invitationId} AND state<>'Delivered'`; - yield* sql`INSERT INTO public.applicant_account_audit VALUES(${input.invitationId + ":revoke:" + row.invitationId},${applicantId},${row.invitationId},${input.actor},'Revoked',${input.now})`; + yield* sql`INSERT INTO public.applicant_account_audit VALUES(${input.invitationId + ":revoke:" + row.invitationId},${applicantId},${row.invitationId},${input.coordinator.personId},'Revoked',${input.now})`; } if (input.command.action === "Revoke") return; @@ -80,9 +85,9 @@ export const commandOnboarding = (input: { DateTime.add(DateTime.makeUnsafe(input.now), { days: 1 }), ); - yield* sql`INSERT INTO public.applicant_account_invitations(invitation_id,application_id,applicant_id,token_digest,expires_at,state,issued_by,issued_at) VALUES(${input.invitationId},${input.command.applicationId},${applicantId},${input.digest},${expiresAt},'Open',${input.actor},${input.now})`; + yield* sql`INSERT INTO public.applicant_account_invitations(invitation_id,application_id,applicant_id,token_digest,expires_at,state,issued_by,issued_at) VALUES(${input.invitationId},${input.command.applicationId},${applicantId},${input.digest},${expiresAt},'Open',${input.coordinator.personId},${input.now})`; yield* sql`INSERT INTO public.applicant_account_delivery(invitation_id,state,secret,recipient) SELECT ${input.invitationId},'Pending',${input.token},email FROM public.admission_applicants WHERE applicant_id=${applicantId}`; - yield* sql`INSERT INTO public.applicant_account_audit VALUES(${input.invitationId + ":issue"},${applicantId},${input.invitationId},${input.actor},'Issued',${input.now})`; + yield* sql`INSERT INTO public.applicant_account_audit VALUES(${input.invitationId + ":issue"},${applicantId},${input.invitationId},${input.coordinator.personId},'Issued',${input.now})`; }), ); diff --git a/packages/database/src/placements/service.test.ts b/packages/database/src/placements/service.test.ts index 25e18693..e774aadd 100644 --- a/packages/database/src/placements/service.test.ts +++ b/packages/database/src/placements/service.test.ts @@ -1,3 +1,4 @@ +import { administratorDepartmentReach } from "@vektorprogrammet/domain/organization/authority-fixtures"; import { expect, layer } from "@effect/vitest"; import { Database } from "../service.js"; import { DatabaseTestLive } from "../test-support/platform.js"; @@ -20,9 +21,24 @@ const scope = { const personId = PersonId.make("command-volunteer"); +/** Self modes act as the volunteer; board and coverage modes as a coordinator of the scope. */ const command = (mutation: PlacementMutation, commandId = "a".repeat(64)) => Placements.use((placements) => - placements.execute({ mutation, actor: personId, now, commandId }, () => Effect.void), + placements.execute( + mutation.mode === "board" || mutation.mode === "coverage" + ? { + mutation, + coordinator: administratorDepartmentReach( + personId, + "placements.coordinate", + mutation.scope.departmentId, + ), + now, + commandId, + } + : { mutation, actor: personId, now, commandId }, + () => Effect.void, + ), ); const seed = Database.use((sql) => diff --git a/packages/database/src/placements/service.ts b/packages/database/src/placements/service.ts index 68ad1f4a..7e1adb4e 100644 --- a/packages/database/src/placements/service.ts +++ b/packages/database/src/placements/service.ts @@ -89,7 +89,18 @@ export const PlacementsLive = Layer.effect( checkPrecondition: (current: PlacementSnapshot) => Effect.Effect, ) => Effect.gen(function* () { - const { mutation, actor, now, commandId } = input; + const { mutation, now, commandId } = input; + + // A coordinator acts only in the department its evidence names. + if ( + "coordinator" in input && + input.coordinator.departmentId !== mutation.scope.departmentId + ) + return yield* Effect.die( + new Error("placement coordinator evidence names another department"), + ); + + const actor = "coordinator" in input ? input.coordinator.personId : input.actor; yield* run(lockPlacementDepartment(mutation.scope.departmentId)); return yield* Match.value(mutation).pipe( diff --git a/packages/domain/src/admissions/service.ts b/packages/domain/src/admissions/service.ts index ade0f62d..5cead189 100644 --- a/packages/domain/src/admissions/service.ts +++ b/packages/domain/src/admissions/service.ts @@ -10,7 +10,7 @@ import { type AdmissionPeriodCommand, } from "../admission-period/schema.js"; import type { OrganizationPersonAuthority } from "../organization/authority.js"; -import type { PersonId } from "../organization/schema.js"; +import type { DepartmentReach } from "../authz/reach.js"; import type { AdmissionOutcomeBoard, AdmissionOutcomeCommand, @@ -86,7 +86,8 @@ export interface AdmissionsOperations { input: { readonly applicationId: AdmissionOutcomeEntry["applicationId"]; readonly command: AdmissionOutcomeCommand; - readonly actor: PersonId; + /** Reach over the application's department; another department's evidence is denied. */ + readonly decider: DepartmentReach<"admissions.outcomes">; readonly now: string; }, checkPrecondition: (current: AdmissionOutcomeEntry) => Effect.Effect, diff --git a/packages/domain/src/authz/AGENTS.md b/packages/domain/src/authz/AGENTS.md index 194588c1..03429783 100644 --- a/packages/domain/src/authz/AGENTS.md +++ b/packages/domain/src/authz/AGENTS.md @@ -70,6 +70,12 @@ No upstream context. | -------------------------------- | -------------------- | | `@vektorprogrammet/domain/authz` | [index.ts](index.ts) | +## Constructs + +The shared constructs defined here. Each name links to its contract; [docs/constructs.md](../../../../docs/constructs.md) indexes them all. + +- [`requireDepartmentReach`](../../../../docs/constructs/authority-evidence.md#requiredepartmentreach) (authority-evidence): Checks that a resolved authority reaches one department with a capability, and returns the evidence that a department-scoped command requires. + Local invariants, pitfalls, and recipes go below this generated part; `just guides write` keeps them. [//]: # "guide: end" diff --git a/packages/domain/src/authz/reach-evidence.test.ts b/packages/domain/src/authz/reach-evidence.test.ts new file mode 100644 index 00000000..ad355ef6 --- /dev/null +++ b/packages/domain/src/authz/reach-evidence.test.ts @@ -0,0 +1,228 @@ +import { expect, expectTypeOf, it } from "@effect/vitest"; +import { Effect, Predicate, Result, Schema } from "effect"; +import { Arbitrary } from "effect/unstable/arbitrary"; +import { admissionOutcomePermission } from "../admissions/outcome.js"; +import { + mapOrganizationAuthorityToDepartmentActor, + OrganizationAuthorityBoardSeatSchema, + OrganizationAuthorityMembershipSchema, + OrganizationGlobalAdministratorStatusSchema, + OrganizationPersonAuthoritySchema, +} from "../organization/authority.js"; +import { spec0055OrganizationAuthorityFixtures } from "../organization/authority-fixtures.test-support.js"; +import { DepartmentId, TeamId } from "../organization/schema.js"; +import { canManagePlacements } from "../placements/policy.js"; +import type { PlacementExecution } from "../placements/service.js"; +import type { AdmissionsOperations } from "../admissions/service.js"; +import { + Delegation, + DelegationArea, + DelegationId, + ORGANIZATION_CAPABILITIES, + ORGANIZATION_CAPABILITY_IDS, + OrganizationCapability, +} from "./delegation.js"; +import { + type DepartmentReach, + DepartmentReachDenied, + reaches, + ReachTarget, + requireDepartmentReach, +} from "./reach.js"; + +const fixtures = spec0055OrganizationAuthorityFixtures({ + evaluatedAt: "2026-09-28T12:00:00.000Z", + departmentId: "reach-department-a", + teamId: "reach-team", + persons: { + administrator: "reach-administrator", + leader: "reach-leader", + inactiveLeader: "reach-inactive-leader", + member: "reach-member", + absent: "reach-absent", + }, + memberships: { + leader: "reach-leader-membership", + inactiveLeader: "reach-inactive-leader-membership", + member: "reach-member-membership", + }, +}); + +/** A small pool, so generated appointments land in the department under test. */ +const departments = ["reach-department-a", "reach-department-b", "reach-department-c"] as const; + +const department = Arbitrary.map(Arbitrary.schema(Schema.Literals(departments)), (id) => + DepartmentId.make(id), +); + +/** A small pool of teams, shared by appointments and delegations, so delegations can apply. */ +const teams = ["reach-team-a", "reach-team-b"] as const; + +const team = Arbitrary.map(Arbitrary.schema(Schema.Literals(teams)), (id) => TeamId.make(id)); + +const membership = Arbitrary.map( + Arbitrary.all({ + fields: Arbitrary.schema(OrganizationAuthorityMembershipSchema), + department, + team, + }), + ({ fields, department, team }) => ({ ...fields, departmentId: department, teamId: team }), +); + +/** + * Delegations of the pooled teams, current, ended, or future at the fixtures' instant. Their + * interval is chosen, not generated: the RFC 3339 filter exhausts the schema arbitrary. + */ +const delegation = Arbitrary.map( + Arbitrary.all({ + team, + capability: Arbitrary.schema(OrganizationCapability), + area: Arbitrary.schema(Schema.Literals(["Organization", ...departments])), + holders: Arbitrary.schema(Schema.Literals(["AllMembers", "LeadersOnly"])), + interval: Arbitrary.schema(Schema.Literals(["Current", "Ended", "Future"])), + }), + ({ team, capability, area, holders, interval }) => + Delegation.make({ + delegationId: DelegationId.make(`delegation-${"c".repeat(64)}`), + name: "Generated delegation", + teamId: team, + capability, + area: + area === "Organization" + ? DelegationArea.cases.Organization.make({}) + : DelegationArea.cases.Department.make({ departmentId: DepartmentId.make(area) }), + holders, + startAt: interval === "Future" ? "2026-10-01T00:00:00.000Z" : "2026-01-01T00:00:00.000Z", + endAt: interval === "Ended" ? "2026-06-01T00:00:00.000Z" : null, + revision: 0, + }), +); + +/** + * A fixture person and instant with every global-administrator status, generated team and board + * appointments in the pooled departments and teams, national board seats, and delegations. + * Instants are not generated: the RFC 3339 filter exhausts the schema arbitrary. + */ +const authority = Arbitrary.map( + Arbitrary.all({ + base: Arbitrary.schema( + Schema.Literals(["administrator", "leader", "inactiveLeader", "member", "absent"]), + ), + globalAdministrator: Arbitrary.schema(OrganizationGlobalAdministratorStatusSchema), + memberships: Arbitrary.array(membership, { maxLength: 4 }), + nationalBoardSeats: Arbitrary.schema(Schema.Array(OrganizationAuthorityBoardSeatSchema)), + delegations: Arbitrary.array(delegation, { maxLength: 2 }), + }), + ({ base, ...facts }) => OrganizationPersonAuthoritySchema.make({ ...fixtures[base], ...facts }), +); + +const capability = Arbitrary.schema(OrganizationCapability); + +const propertyOptions = { arbitrary: { seed: 28092026, runs: 300 } } as const; + +it.effect("requireDepartmentReach returns evidence for a board leader and denies a member", () => + Effect.gen(function* () { + const departmentId = DepartmentId.make("reach-department-a"); + + const leader = yield* Effect.fromResult( + requireDepartmentReach(fixtures.leader, "admissions.outcomes", departmentId), + ); + + expect(leader).toMatchObject({ + personId: fixtures.leader.personId, + capability: "admissions.outcomes", + departmentId, + }); + + const denied = yield* Effect.flip( + Effect.fromResult( + requireDepartmentReach(fixtures.member, "admissions.outcomes", departmentId), + ), + ); + + expect(denied).toEqual( + new DepartmentReachDenied({ + personId: fixtures.member.personId, + capability: "admissions.outcomes", + departmentId, + }), + ); + }), +); + +it.prop( + "requireDepartmentReach grants evidence exactly where reaches holds, naming the person, capability, and department", + { authority, capability, department }, + ({ authority, capability, department }) => { + const result = requireDepartmentReach(authority, capability, department); + + expect(Result.isSuccess(result)).toBe( + reaches(authority, capability, ReachTarget.Department({ departmentId: department })), + ); + + const facts = Result.isSuccess(result) ? result.success : result.failure; + expect(facts.personId).toBe(authority.personId); + expect(facts.capability).toBe(capability); + expect(facts.departmentId).toBe(department); + }, + propertyOptions, +); + +it.prop( + "requireDepartmentReach agrees with department administration where a global administrator holds the capability", + { authority, department }, + ({ authority, department }) => { + for (const capability of ORGANIZATION_CAPABILITY_IDS) { + if (!ORGANIZATION_CAPABILITIES[capability].globalAdministrator) continue; + + const decision = mapOrganizationAuthorityToDepartmentActor(authority, capability, department); + + expect(Result.isSuccess(requireDepartmentReach(authority, capability, department))).toBe( + Predicate.isTagged(decision, "Allow") && !Predicate.isTagged(decision.value, "Member"), + ); + } + }, + propertyOptions, +); + +it.prop( + "requireDepartmentReach replaces the placement coordinator and outcome decider checks", + { authority, department }, + ({ authority, department }) => { + expect( + Result.isSuccess(requireDepartmentReach(authority, "placements.coordinate", department)), + ).toBe(canManagePlacements(authority, department)); + + expect( + Result.isSuccess(requireDepartmentReach(authority, "admissions.outcomes", department)), + ).toBe(admissionOutcomePermission(authority, department) === "Decide"); + }, + propertyOptions, +); + +it("requireDepartmentReach evidence is the only way to satisfy a department-scoped command", () => { + type OutcomeDecider = Parameters[0]["decider"]; + + type Coordinator = Extract["coordinator"]; + + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf>(); + + // Negative controls: a literal of the evidence's visible shape, or another capability's + // evidence, is not evidence for the command. + expectTypeOf<{ + readonly personId: OutcomeDecider["personId"]; + readonly capability: "admissions.outcomes"; + readonly departmentId: OutcomeDecider["departmentId"]; + }>().not.toExtend(); + expectTypeOf>().not.toExtend(); + expectTypeOf>().not.toExtend(); + + // A board or coverage-board change cannot run on a person's own actor id. + expectTypeOf<{ + readonly mutation: Extract["mutation"]; + readonly actor: OutcomeDecider["personId"]; + readonly now: string; + readonly commandId: string; + }>().not.toExtend(); +}); diff --git a/packages/domain/src/authz/reach.ts b/packages/domain/src/authz/reach.ts index a20d450c..8dcf2906 100644 --- a/packages/domain/src/authz/reach.ts +++ b/packages/domain/src/authz/reach.ts @@ -3,7 +3,7 @@ * `delegatedReach`). Every decision that depends on team or board leadership, or on a delegation, * asks this module; no other product code reads a leadership flag. */ -import { Data, Match, Option, Order } from "effect"; +import { Data, Match, Option, Order, Result } from "effect"; import type { OrganizationPersonAuthority } from "../organization/authority.js"; import type { DepartmentId, MembershipId, PersonId, TeamId } from "../organization/schema.js"; import { @@ -144,6 +144,71 @@ export const reaches = ( target: ReachTarget, ): boolean => reachScopes(authority, capability).some((scope) => scopeCovers(scope, target)); +/** Type-only brand. No module exports a value of it, so only {@link requireDepartmentReach} builds the evidence. */ +declare const DepartmentReachBrand: unique symbol; + +/** + * Proof that a person held `capability` over one department when their authority was resolved. + * A command takes the department from it, so the proof cannot be pointed at another department. + */ +export interface DepartmentReach { + readonly [DepartmentReachBrand]: C; + readonly personId: PersonId; + readonly capability: C; + readonly departmentId: DepartmentId; +} + +/** The person does not hold the capability over the department. */ +export class DepartmentReachDenied extends Data.TaggedError("DepartmentReachDenied")<{ + readonly personId: PersonId; + readonly capability: OrganizationCapability; + readonly departmentId: DepartmentId; +}> {} + +/** + * Checks that a resolved authority reaches one department with a capability, and returns the + * evidence that a department-scoped command requires. + * + * @remarks + * It is the only constructor of {@link DepartmentReach} and decides exactly as {@link reaches} + * with a department target: a board leadership of the independent department, a national board + * leadership, a delegation over the department or the organization, or an active global + * administrator where the capability admits one. A team leadership never reaches a department. + * + * @sideEffects none + * + * @example + * ```ts + * const coordinator = yield* Effect.fromResult( + * requireDepartmentReach(authority, "placements.coordinate", departmentId), + * ); + * yield* placements.execute({ mutation, coordinator, now, commandId }); + * ``` + * + * @avoid Checking `reaches` at the call site and passing a department and a person to the + * command: the command then trusts that some caller checked, for that department. Require the + * evidence, and take the department from it. + * + * @construct authority-evidence + */ +export const requireDepartmentReach = ( + /** The person's authority, resolved from current facts inside the command transaction. */ + authority: OrganizationPersonAuthority, + /** The capability that the command needs. */ + capability: C, + /** The department that the command acts in. */ + departmentId: DepartmentId, +): /** Evidence for the department, or the typed denial. */ +Result.Result, DepartmentReachDenied> => + reaches(authority, capability, ReachTarget.Department({ departmentId })) + ? Result.succeed( + // SAFETY: the one constructor of the evidence brand; the reach check above is what it proves. + { personId: authority.personId, capability, departmentId } as DepartmentReach, + ) + : Result.fail( + new DepartmentReachDenied({ personId: authority.personId, capability, departmentId }), + ); + /** The departments that the person reaches as a whole. */ export type ReachedDepartments = Data.TaggedEnum<{ All: {}; diff --git a/packages/domain/src/organization/authority-fixtures.test-support.ts b/packages/domain/src/organization/authority-fixtures.test-support.ts index 8c3c763f..b7c8fde6 100644 --- a/packages/domain/src/organization/authority-fixtures.test-support.ts +++ b/packages/domain/src/organization/authority-fixtures.test-support.ts @@ -1,3 +1,6 @@ +import { Result } from "effect"; +import type { OrganizationCapability } from "../authz/delegation.js"; +import { requireDepartmentReach, type DepartmentReach } from "../authz/reach.js"; import type { OrganizationPersonAuthority } from "./authority.js"; import { DepartmentId, MembershipId, PersonId, TeamId } from "./schema.js"; @@ -113,3 +116,33 @@ export const spec0055OrganizationAuthorityFixtures = ( }, }; }; + +/** + * The resolved authority of an active global administrator with no appointments, at a fixed + * instant. Tests and proofs mint evidence from it; production resolves authority from facts. + */ +export const activeAdministratorAuthority = (personId: string): OrganizationPersonAuthority => ({ + personId: PersonId.make(personId), + evaluatedAt: "2026-09-28T12:00:00.000Z", + globalAdministrator: "Active", + memberships: [], + nationalBoardSeats: [], + delegations: [], +}); + +/** + * Department reach evidence for an active global administrator, for tests and proofs that call a + * department-scoped command directly. A capability that no global administrator holds fails. + */ +export const administratorDepartmentReach = ( + personId: string, + capability: C, + departmentId: string, +): DepartmentReach => + Result.getOrThrow( + requireDepartmentReach( + activeAdministratorAuthority(personId), + capability, + DepartmentId.make(departmentId), + ), + ); diff --git a/packages/domain/src/placements/service.ts b/packages/domain/src/placements/service.ts index d0d13eca..8ec068f0 100644 --- a/packages/domain/src/placements/service.ts +++ b/packages/domain/src/placements/service.ts @@ -1,5 +1,6 @@ import { Context, Data, type Effect } from "effect"; import type { DepartmentId, OrganizationPersonAuthority, PersonId } from "../organization/index.js"; +import type { DepartmentReach } from "../authz/reach.js"; import type { CertificateCommandTarget, CertificateAssistant, @@ -61,12 +62,26 @@ export type PlacementMutation = export type PlacementSnapshot = Affiliation | PlacementBoard | OwnCoverageView | CoverageBoard; -export interface PlacementExecution { - readonly mutation: PlacementMutation; - readonly actor: PersonId; - readonly now: string; - readonly commandId: string; -} +/** + * One placement mutation. A person changes their own affiliation or coverage as themselves; a + * board or coverage-board change needs a coordinator's reach over the mutation's department. + */ +export type PlacementExecution = + | { + readonly mutation: Extract< + PlacementMutation, + { readonly mode: "affiliation" | "ownCoverage" } + >; + readonly actor: PersonId; + readonly now: string; + readonly commandId: string; + } + | { + readonly mutation: Extract; + readonly coordinator: DepartmentReach<"placements.coordinate">; + readonly now: string; + readonly commandId: string; + }; /** One page of the assistants of a department and semester, with the scope's labels. */ export interface DaysServedPage extends AssistantPage { diff --git a/tools/verification/onboarding-delivery.test.ts b/tools/verification/onboarding-delivery.test.ts index 008f43f5..eed29bd2 100644 --- a/tools/verification/onboarding-delivery.test.ts +++ b/tools/verification/onboarding-delivery.test.ts @@ -1,3 +1,4 @@ +import { administratorDepartmentReach } from "@vektorprogrammet/domain/organization/authority-fixtures"; import { randomBytes } from "node:crypto"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Database } from "@vektorprogrammet/database"; @@ -22,6 +23,8 @@ const dept = DepartmentId.make("onboarding-dept"); const actor = PersonId.make("onboarding-leader"); +const coordinator = administratorDepartmentReach("onboarding-leader", "admissions.outcomes", dept); + // The drain draws its claim identity from Crypto; no digest belongs to this lifecycle. const testCrypto = Crypto.make({ randomBytes, digest: () => Effect.die("unexpected digest") }); @@ -51,12 +54,11 @@ const issue = (i: number) => Database.use((sql) => sql.withTransaction( commandOnboarding({ - departmentId: dept, + coordinator, command: { applicationId: PublicApplicationIdSchema.make("onboard-app-" + i), action: "Issue", }, - actor, now, invitationId: "onboard-invite-" + i, token: "onboard_" + String(i).repeat(64), @@ -154,12 +156,11 @@ describe("onboarding delivery lifecycle", () => { Database.use((sql) => sql.withTransaction( commandOnboarding({ - departmentId: dept, + coordinator, command: { applicationId: PublicApplicationIdSchema.make("onboard-app-5"), action: "Issue", }, - actor, now: "2000-01-01T00:00:00.000Z", invitationId: "expired-invitation", token: "onboard_" + "8".repeat(64), From 3a128ccfac187df271495471b36cef8fa3a1d5c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:30:20 +0000 Subject: [PATCH 4/9] refactor(authz): run authorized commands from their evidence Four commands authorized before replay and then authorized again inside the command, each resolving current authority twice in one transaction. Their authorize step now returns branded evidence, and the command runs from it without resolving authority again: - Schools: SchoolCommandAuthorization carries the decoded command and the locked school and departments; executeCommand takes only it. - Recruitment maintenance: RecruitmentMaintenanceAuthorization carries the decoded command; maintainRecruitment takes only it. - Certificates: authorizeCertificateCommand and CertificateCommandTarget give way to DaysServedConfirmationAuthorization and CertificateIssueAuthorization; the scope, the assistant, the department row, and the issuer's seat come from the evidence. - Team applications: authorize returns TeamApplicationAuthorization for the action; delete and revise take their application or team from it. Schools and maintenance resolved authority at DateTime.now twice; they now resolve it once. Each evidence is valid only in the transaction that resolved it, which is the transaction the command runs in. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../src/placements/certificates-http.ts | 34 ++++------ .../src/recruitment/maintenance-http.ts | 7 +- .../src/schools/administration-http.ts | 9 +-- apps/backend/src/team-application/http.ts | 17 +++-- .../src/placements/certificates.test.ts | 59 ++++++++++------- .../database/src/placements/certificates.ts | 64 ++++++++++++------- packages/database/src/placements/service.ts | 19 ++++-- .../src/recruitment/maintenance-postgres.ts | 9 ++- .../src/recruitment/postgres-layer.ts | 4 +- .../database/src/schools/administration.ts | 11 ++-- .../database/src/schools/postgres-layer.ts | 4 +- .../src/team-application/delivery.test.ts | 28 +++++--- .../database/src/team-application/postgres.ts | 45 +++++++++---- .../src/team-application/service.test.ts | 47 ++++++++++---- .../database/src/team-application/service.ts | 10 +-- packages/domain/src/placements/certificate.ts | 37 +++++++++-- packages/domain/src/placements/service.ts | 38 +++++++---- .../domain/src/recruitment/maintenance.ts | 15 +++++ packages/domain/src/recruitment/service.ts | 8 ++- packages/domain/src/schools/administration.ts | 26 +++++++- packages/domain/src/schools/service.ts | 8 ++- .../domain/src/team-application/service.ts | 39 +++++++++-- 22 files changed, 362 insertions(+), 176 deletions(-) diff --git a/apps/backend/src/placements/certificates-http.ts b/apps/backend/src/placements/certificates-http.ts index aa36dc4b..cdc74315 100644 --- a/apps/backend/src/placements/certificates-http.ts +++ b/apps/backend/src/placements/certificates-http.ts @@ -10,7 +10,6 @@ import { type CredentialOutcome, DomainId, Scope } from "@vektorprogrammet/domai import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; import type { DepartmentId, PersonId, SemesterId } from "@vektorprogrammet/domain/organization"; import { - CertificateCommandTarget, daysServedEntryVersion, Placements, type CertificateCommandFailure, @@ -364,12 +363,13 @@ const confirmDaysServed = ( const current = yield* reader(request); // Authority is current before any stored response can be replayed. - yield* Placements.use((placements) => - placements.authorizeCertificateCommand( - current.principal, - CertificateCommandTarget.ConfirmDaysServed({ departmentId, semesterId }), - ), + const authorization = yield* Placements.use((placements) => + placements.authorizeDaysServedConfirmation(current.principal, { + departmentId, + semesterId, + }), ); + yield* authorizeReader(request, ConfirmDaysServedEndpoint, current, departmentId); const identity = yield* httpIdentity({ @@ -390,14 +390,8 @@ const confirmDaysServed = ( }, execute: Placements.use((placements) => placements.confirmDaysServed( - current.principal, - { - commandId: identity.identitySha256, - departmentId, - semesterId, - personId, - total: input.value.total, - }, + authorization, + { commandId: identity.identitySha256, personId, total: input.value.total }, (entry) => requireCurrentETag(entryETag(entry), ifMatch), ), ).pipe( @@ -439,12 +433,10 @@ const issueCertificate = ( const current = yield* reader(request); // A revoked seat cannot replay a stored certificate. - yield* Placements.use((placements) => - placements.authorizeCertificateCommand( - current.principal, - CertificateCommandTarget.IssueCertificate({ departmentId, personId }), - ), + const authorization = yield* Placements.use((placements) => + placements.authorizeCertificateIssue(current.principal, departmentId, personId), ); + yield* authorizeReader(request, IssueCertificateEndpoint, current, departmentId); const identity = yield* httpIdentity({ @@ -466,8 +458,8 @@ const issueCertificate = ( // The PDF renders before commit, so an unprintable certificate records no issue. execute: Placements.use((placements) => placements.issueCertificate( - current.principal, - { commandId: identity.identitySha256, departmentId, personId }, + authorization, + { commandId: identity.identitySha256 }, (preview) => requireCurrentETag(certificateETag(preview), ifMatch), ), ).pipe( diff --git a/apps/backend/src/recruitment/maintenance-http.ts b/apps/backend/src/recruitment/maintenance-http.ts index 484ae638..0f72996a 100644 --- a/apps/backend/src/recruitment/maintenance-http.ts +++ b/apps/backend/src/recruitment/maintenance-http.ts @@ -141,8 +141,11 @@ export const maintainRecruitmentHttp = (request: Request) => const outcome = yield* executeNativeHttpCommandPostgres( Effect.gen(function* () { const personId = yield* authorizeTransport(request, "command"); + // Current authority is resolved inside the receipt transaction, before replay. - yield* Recruitment.use((service) => service.authorizeMaintenance(command, personId)); + const authorization = yield* Recruitment.use((service) => + service.authorizeMaintenance(command, personId), + ); // A person subject, this fixed operation and target, and a parsed key always derive. const identity = deriveHttpIdentity({ @@ -160,7 +163,7 @@ export const maintainRecruitmentHttp = (request: Request) => }, execute: Effect.gen(function* () { const result = yield* Recruitment.use((service) => - service.maintainRecruitment(command, personId), + service.maintainRecruitment(authorization), ); // The domain produced the result, so encoding it cannot fail. diff --git a/apps/backend/src/schools/administration-http.ts b/apps/backend/src/schools/administration-http.ts index 025f1c1e..ffd00e4a 100644 --- a/apps/backend/src/schools/administration-http.ts +++ b/apps/backend/src/schools/administration-http.ts @@ -102,7 +102,10 @@ export const executeSchoolCommandHttp = (request: Request) => { const outcome = yield* executeNativeHttpCommandPostgres( Effect.gen(function* () { const personId = yield* authorizeTransport(request, true, presentation); - yield* Schools.use((schools) => schools.authorizeCommand(command, personId)); + + const authorization = yield* Schools.use((schools) => + schools.authorizeCommand(command, personId), + ); const identity = yield* httpIdentity({ credentialSubject: `Person:${personId}`, @@ -118,9 +121,7 @@ export const executeSchoolCommandHttp = (request: Request) => { operationId, }, execute: Effect.gen(function* () { - const result = yield* Schools.use((schools) => - schools.executeCommand(command, personId), - ); + const result = yield* Schools.use((schools) => schools.executeCommand(authorization)); const encoded = yield* Schema.encodeEffect(SchoolCommandResult)(result); diff --git a/apps/backend/src/team-application/http.ts b/apps/backend/src/team-application/http.ts index ce0cbbb4..f237ee19 100644 --- a/apps/backend/src/team-application/http.ts +++ b/apps/backend/src/team-application/http.ts @@ -488,7 +488,7 @@ const deleteApplication = (request: Request, applicationId: TeamApplicationId) = const staff = yield* staffPrincipal(request); // Authority is current before any stored response can be replayed. - const actor = yield* TeamApplications.use((service) => + const authorization = yield* TeamApplications.use((service) => service.authorize( staff.principal, TeamApplicationAction.DeleteTeamApplication({ applicationId }), @@ -498,7 +498,7 @@ const deleteApplication = (request: Request, applicationId: TeamApplicationId) = yield* authorizeStaff( DeleteTeamApplicationEndpoint, staff, - actor, + authorization.actor, "ExactlyOne", presentation, ); @@ -519,10 +519,9 @@ const deleteApplication = (request: Request, applicationId: TeamApplicationId) = operationId, }, execute: TeamApplications.use((service) => - service.deleteApplication( - { commandId: TeamApplicationCommandId.make(identity.commandId), applicationId }, - staff.principal, - ), + service.deleteApplication(authorization, { + commandId: TeamApplicationCommandId.make(identity.commandId), + }), ).pipe( Effect.as({ status: 204, @@ -586,7 +585,7 @@ const reviseIntake = (request: Request, teamId: TeamId) => const staff = yield* staffPrincipal(request); // Authority is current before any stored response can be replayed. - const actor = yield* TeamApplications.use((service) => + const authorization = yield* TeamApplications.use((service) => service.authorize( staff.principal, TeamApplicationAction.ReviseTeamApplicationIntake({ teamId }), @@ -596,7 +595,7 @@ const reviseIntake = (request: Request, teamId: TeamId) => yield* authorizeStaff( ReviseTeamApplicationIntakeEndpoint, staff, - actor, + authorization.actor, "ExactlyOne", presentation, ); @@ -626,7 +625,7 @@ const reviseIntake = (request: Request, teamId: TeamId) => operationId, }, execute: TeamApplications.use((service) => - service.reviseIntake(command, staff.principal, (current) => + service.reviseIntake(authorization, command, (current) => requireCurrentETag(intakeETag(teamId, current.revision), ifMatch), ), ).pipe( diff --git a/packages/database/src/placements/certificates.test.ts b/packages/database/src/placements/certificates.test.ts index 75b154b5..c4b6222c 100644 --- a/packages/database/src/placements/certificates.test.ts +++ b/packages/database/src/placements/certificates.test.ts @@ -103,11 +103,17 @@ const confirm = ( return yield* transaction( Placements.use((placements) => - placements.confirmDaysServed( - reader, - { commandId: nextCommandId(), departmentId, semesterId, personId, total }, - () => Effect.void, - ), + placements + .authorizeDaysServedConfirmation(reader, { departmentId, semesterId }) + .pipe( + Effect.flatMap((authorization) => + placements.confirmDaysServed( + authorization, + { commandId: nextCommandId(), personId, total }, + () => Effect.void, + ), + ), + ), ), ); }); @@ -118,11 +124,17 @@ const issue = (actor: PersonId, departmentId: DepartmentId, personId: PersonId) return yield* transaction( Placements.use((placements) => - placements.issueCertificate( - reader, - { commandId: nextCommandId(), departmentId, personId }, - () => Effect.void, - ), + placements + .authorizeCertificateIssue(reader, departmentId, personId) + .pipe( + Effect.flatMap((authorization) => + placements.issueCertificate( + authorization, + { commandId: nextCommandId() }, + () => Effect.void, + ), + ), + ), ), ); }); @@ -537,20 +549,23 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "60 seconds" })( const rejected = yield* Effect.flip( transaction( Placements.use((placements) => - placements.confirmDaysServed( - reader, - { - commandId: nextCommandId(), + placements + .authorizeDaysServedConfirmation(reader, { departmentId: trondheim, semesterId: autumn, - personId: person.bo, - total: 5, - }, - (current) => - daysServedEntryVersion(current) === stale - ? Effect.void - : Effect.fail("precondition.failed" as const), - ), + }) + .pipe( + Effect.flatMap((authorization) => + placements.confirmDaysServed( + authorization, + { commandId: nextCommandId(), personId: person.bo, total: 5 }, + (current) => + daysServedEntryVersion(current) === stale + ? Effect.void + : Effect.fail("precondition.failed" as const), + ), + ), + ), ), ), ); diff --git a/packages/database/src/placements/certificates.ts b/packages/database/src/placements/certificates.ts index 36b11b0e..dc598047 100644 --- a/packages/database/src/placements/certificates.ts +++ b/packages/database/src/placements/certificates.ts @@ -21,7 +21,8 @@ import { CertificateAccessDenied, CertificateAssistant, CertificateAssistantNotFound, - CertificateCommandTarget, + type CertificateIssueAuthorization, + type DaysServedConfirmationAuthorization, certificateContent, CertificateContent, certificateContentSha256, @@ -705,21 +706,41 @@ const authorizeIssue = ( }); /** - * Resolves the principal's current authority for one command on the caller's transaction, with - * the locks that the command takes, so a stored response replays only to a current holder. + * Resolves the principal's current days-served authority in the scope on the caller's transaction, + * under the department lock, so a stored response replays only to a current holder. */ -export const authorizeCertificateCommand = ( +export const authorizeDaysServedConfirmation = ( principal: CertificatePrincipal, - target: CertificateCommandTarget, + scope: PlacementScope, ) => Effect.gen(function* () { const sql = yield* Database; + yield* authorizeConfirmation(sql, principal, scope); - yield* CertificateCommandTarget.$match(target, { - ConfirmDaysServed: (scope) => authorizeConfirmation(sql, principal, scope), - IssueCertificate: ({ departmentId, personId }) => - Effect.asVoid(authorizeIssue(sql, principal, departmentId, personId)), - }); + // SAFETY: the one constructor of the evidence brand; authorizeConfirmation above is what it proves. + return { + principal, + departmentId: scope.departmentId, + semesterId: scope.semesterId, + } as DaysServedConfirmationAuthorization; + }); + +/** + * Resolves whether the principal currently issues the department's certificates for another + * person, on the caller's transaction and under the department lock, so a stored response replays + * only to a current issuer. + */ +export const authorizeCertificateIssue = ( + principal: CertificatePrincipal, + departmentId: DepartmentId, + personId: PersonId, +) => + Effect.gen(function* () { + const sql = yield* Database; + const { department, issuer } = yield* authorizeIssue(sql, principal, departmentId, personId); + + // SAFETY: the one constructor of the evidence brand; authorizeIssue above is what it proves. + return { principal, department, personId, issuer } as CertificateIssueAuthorization; }); /** @@ -727,15 +748,15 @@ export const authorizeCertificateCommand = ( * precondition sees the fresh entry; the earlier confirmations and the service facts stay. */ export const confirmDaysServed = ( - principal: CertificatePrincipal, - command: ConfirmDaysServedCommand, + authorization: DaysServedConfirmationAuthorization, + input: Omit, checkPrecondition: (current: DaysServedEntry) => Effect.Effect, ) => Effect.gen(function* () { const sql = yield* Database; - const scope = { departmentId: command.departmentId, semesterId: command.semesterId }; - - yield* authorizeConfirmation(sql, principal, scope); + const { principal, departmentId, semesterId } = authorization; + const command = { ...input, departmentId, semesterId }; + const scope = { departmentId, semesterId }; const current = yield* readEntry(scope, command.personId); @@ -846,19 +867,14 @@ export const readCertificate = ( * under which seat, and the hash of the content. The precondition sees the fresh preview. */ export const issueCertificate = ( - principal: CertificatePrincipal, - command: IssueCertificateCommand, + authorization: CertificateIssueAuthorization, + input: Pick, checkPrecondition: (current: CertificatePreview) => Effect.Effect, ) => Effect.gen(function* () { const sql = yield* Database; - - const { department, issuer } = yield* authorizeIssue( - sql, - principal, - command.departmentId, - command.personId, - ); + const { principal, department, personId, issuer } = authorization; + const command = { ...input, departmentId: department.departmentId, personId }; const preview = yield* buildPreview(department, command.personId, issuer); diff --git a/packages/database/src/placements/service.ts b/packages/database/src/placements/service.ts index 7e1adb4e..f8f959b0 100644 --- a/packages/database/src/placements/service.ts +++ b/packages/database/src/placements/service.ts @@ -23,7 +23,8 @@ import { } from "./coverage.js"; import { readPlacementDraft } from "./draft.js"; import { - authorizeCertificateCommand, + authorizeCertificateIssue, + authorizeDaysServedConfirmation, confirmDaysServed, issueCertificate, listCertificates, @@ -169,12 +170,12 @@ export const PlacementsLive = Layer.effect( readCertificateScopes(principal).pipe(Effect.provideService(Database, database)), readDaysServed: (principal, scope, cursor) => readDaysServed(principal, scope, cursor).pipe(Effect.provideService(Database, database)), - authorizeCertificateCommand: (principal, target) => - authorizeCertificateCommand(principal, target).pipe( + authorizeDaysServedConfirmation: (principal, scope) => + authorizeDaysServedConfirmation(principal, scope).pipe( Effect.provideService(Database, database), ), - confirmDaysServed: (principal, command, checkPrecondition) => - confirmDaysServed(principal, command, checkPrecondition).pipe( + confirmDaysServed: (authorization, command, checkPrecondition) => + confirmDaysServed(authorization, command, checkPrecondition).pipe( Effect.provideService(Database, database), ), listCertificates: (principal, departmentId, cursor) => @@ -185,8 +186,12 @@ export const PlacementsLive = Layer.effect( readCertificate(principal, departmentId, personId).pipe( Effect.provideService(Database, database), ), - issueCertificate: (principal, command, checkPrecondition) => - issueCertificate(principal, command, checkPrecondition).pipe( + authorizeCertificateIssue: (principal, departmentId, personId) => + authorizeCertificateIssue(principal, departmentId, personId).pipe( + Effect.provideService(Database, database), + ), + issueCertificate: (authorization, command, checkPrecondition) => + issueCertificate(authorization, command, checkPrecondition).pipe( Effect.provideService(Database, database), ), execute, diff --git a/packages/database/src/recruitment/maintenance-postgres.ts b/packages/database/src/recruitment/maintenance-postgres.ts index 27d73a11..f2b472cc 100644 --- a/packages/database/src/recruitment/maintenance-postgres.ts +++ b/packages/database/src/recruitment/maintenance-postgres.ts @@ -20,6 +20,7 @@ import { InterviewStaffing, InterviewStaffingHistory, InterviewStaffingManagement, + type RecruitmentMaintenanceAuthorization, RecruitmentMaintenanceCommand, RecruitmentMaintenanceResult, RecruitmentMaintenanceFailure, @@ -239,16 +240,18 @@ export const authorizeMaintenance = (command: RecruitmentMaintenanceCommand, per const sql = yield* Database; const decoded = yield* decodeCommand(command).pipe(Effect.mapError(() => fail("Invalid"))); yield* authorizeWithSql(sql, decoded, personId); + + // SAFETY: the one constructor of the evidence brand; authorizeWithSql above is what it proves. + return { personId, command: decoded } as RecruitmentMaintenanceAuthorization; }).pipe(mapFailure); -export const maintainRecruitment = (input: RecruitmentMaintenanceCommand, personId: PersonId) => +export const maintainRecruitment = (authorization: RecruitmentMaintenanceAuthorization) => Effect.gen(function* () { - const command = yield* decodeCommand(input).pipe(Effect.mapError(() => fail("Invalid"))); + const { command, personId } = authorization; const sql = yield* Database; return yield* sql.withTransaction( Effect.gen(function* () { - yield* authorizeWithSql(sql, command, personId); yield* lockAdvisory( sql, AdvisoryLockKey.recruitmentMaintenanceCommand(personId, command.commandId), diff --git a/packages/database/src/recruitment/postgres-layer.ts b/packages/database/src/recruitment/postgres-layer.ts index df5c6701..ad1252cf 100644 --- a/packages/database/src/recruitment/postgres-layer.ts +++ b/packages/database/src/recruitment/postgres-layer.ts @@ -85,8 +85,8 @@ export const RecruitmentLive = Layer.effect( Effect.provideService(Admissions, admissions), Effect.provideService(Profile, profile), ), - maintainRecruitment: (command, personId) => - maintainRecruitment(command, personId).pipe( + maintainRecruitment: (authorization) => + maintainRecruitment(authorization).pipe( Effect.provideService(Database, database), Effect.provideService(Admissions, admissions), Effect.provideService(Profile, profile), diff --git a/packages/database/src/schools/administration.ts b/packages/database/src/schools/administration.ts index ae375e2a..2e2c38b2 100644 --- a/packages/database/src/schools/administration.ts +++ b/packages/database/src/schools/administration.ts @@ -8,6 +8,7 @@ import { SchoolId, SchoolCapacityPlan, SchoolCommand, + type SchoolCommandAuthorization, SchoolCommandResult, ManagedSchool, SchoolDirectoryDepartmentSchema, @@ -154,7 +155,10 @@ export const authorizeSchoolCommand = (command: SchoolCommand, personId: PersonI Effect.gen(function* () { const sql = yield* Database; const decoded = yield* decodeCommand(command).pipe(Effect.mapError(() => fail("Invalid"))); - yield* authorizeWithSql(sql, decoded, personId); + const { school, departments } = yield* authorizeWithSql(sql, decoded, personId); + + // SAFETY: the one constructor of the evidence brand; authorizeWithSql above is what it proves. + return { personId, command: decoded, school, departments } as SchoolCommandAuthorization; }).pipe(mapFailure); export const readSchoolManagement = (personId: PersonId) => @@ -220,14 +224,13 @@ export const readSchoolManagement = (personId: PersonId) => ); }).pipe(mapFailure); -export const executeSchoolCommand = (input: SchoolCommand, personId: PersonId) => +export const executeSchoolCommand = (authorized: SchoolCommandAuthorization) => Effect.gen(function* () { const sql = yield* Database; - const command = yield* decodeCommand(input).pipe(Effect.mapError(() => fail("Invalid"))); + const { command, personId } = authorized; return yield* sql.withTransaction( Effect.gen(function* () { - const authorized = yield* authorizeWithSql(sql, command, personId); yield* lockAdvisory(sql, AdvisoryLockKey.schoolsCommand(personId, command.commandId)); const digest = sha256Hex( diff --git a/packages/database/src/schools/postgres-layer.ts b/packages/database/src/schools/postgres-layer.ts index d149ac9b..1059e476 100644 --- a/packages/database/src/schools/postgres-layer.ts +++ b/packages/database/src/schools/postgres-layer.ts @@ -19,8 +19,8 @@ export const SchoolsLive: Layer.Layer = Layer.effect( readSchoolManagement(personId).pipe(Effect.provideService(Database, database)), authorizeCommand: (command, personId) => authorizeSchoolCommand(command, personId).pipe(Effect.provideService(Database, database)), - executeCommand: (command, personId) => - executeSchoolCommand(command, personId).pipe(Effect.provideService(Database, database)), + executeCommand: (authorization) => + executeSchoolCommand(authorization).pipe(Effect.provideService(Database, database)), listDirectory: (input) => listSchoolDirectoryPostgres(input).pipe(Effect.provideService(Database, database)), }); diff --git a/packages/database/src/team-application/delivery.test.ts b/packages/database/src/team-application/delivery.test.ts index 2fcbd756..d18f76ef 100644 --- a/packages/database/src/team-application/delivery.test.ts +++ b/packages/database/src/team-application/delivery.test.ts @@ -24,6 +24,7 @@ import { TeamApplicationOutboxDelivery, TeamApplications, type TeamApplicationInput, + TeamApplicationAction, } from "@vektorprogrammet/domain/team-application"; import { DatabaseTestLive } from "../test-support/platform.js"; import { Database } from "../service.js"; @@ -114,16 +115,23 @@ const deleteApplication = (applicationId: string, command: string) => yield* Database.use((sql) => sql.withTransaction( TeamApplications.use((service) => - service.deleteApplication( - { - commandId: TeamApplicationCommandId.make(command), - applicationId: TeamApplicationId.make(applicationId), - }, - { - personId: PersonId.make("pilot-leader"), - authorizationInstant: DateTime.formatIso(now), - }, - ), + service + .authorize( + { + personId: PersonId.make("pilot-leader"), + authorizationInstant: DateTime.formatIso(now), + }, + TeamApplicationAction.DeleteTeamApplication({ + applicationId: TeamApplicationId.make(applicationId), + }), + ) + .pipe( + Effect.flatMap((authorization) => + service.deleteApplication(authorization, { + commandId: TeamApplicationCommandId.make(command), + }), + ), + ), ), ), ); diff --git a/packages/database/src/team-application/postgres.ts b/packages/database/src/team-application/postgres.ts index c3235d20..876c7634 100644 --- a/packages/database/src/team-application/postgres.ts +++ b/packages/database/src/team-application/postgres.ts @@ -15,6 +15,7 @@ import { TeamApplication, TeamApplicationAccessDenied, TeamApplicationAction, + type TeamApplicationAuthorization, TeamApplicationCommandConflict, TeamApplicationConfirmation, TeamApplicationId, @@ -404,10 +405,7 @@ const applicationTeam = (applicationId: TeamApplicationId, forDeletion: boolean) ), ); -export const authorizeTeamApplicationAction = ( - principal: TeamApplicationPrincipal, - action: TeamApplicationAction, -) => +const resolveActionActor = (principal: TeamApplicationPrincipal, action: TeamApplicationAction) => TeamApplicationAction.$match(action, { ReadTeamApplications: ({ teamId }) => resolveTeamActor(principal, teamId, false), ReviseTeamApplicationIntake: ({ teamId }) => authorizeIntakeRevision(principal, teamId), @@ -421,6 +419,22 @@ export const authorizeTeamApplicationAction = ( ), }); +/** + * Resolves current authority for one staff action on the caller's transaction, with the row locks + * a change takes, before a transport replay. + */ +export const authorizeTeamApplicationAction = ( + principal: TeamApplicationPrincipal, + action: A, +) => + resolveActionActor(principal, action).pipe( + Effect.map( + (actor) => + // SAFETY: the one constructor of the evidence brand; resolveActionActor above is what it proves. + ({ principal, action, actor }) as TeamApplicationAuthorization, + ), + ); + export const readPublicTeamApplicationIntake = (teamId: TeamId) => Effect.gen(function* () { const team = yield* findTeamIntake({ teamId, lock: "None" }); @@ -566,7 +580,7 @@ export const readTeamApplication = ( applicationId: TeamApplicationId, ) => Effect.gen(function* () { - const actor = yield* authorizeTeamApplicationAction( + const actor = yield* resolveActionActor( principal, TeamApplicationAction.ReadTeamApplication({ applicationId }), ); @@ -581,14 +595,14 @@ export const readTeamApplication = ( }); export const deleteTeamApplication = ( - command: DeleteTeamApplicationCommand, - principal: TeamApplicationPrincipal, + authorization: TeamApplicationAuthorization< + Extract + >, + input: Pick, ) => Effect.gen(function* () { - const actor = yield* authorizeTeamApplicationAction( - principal, - TeamApplicationAction.DeleteTeamApplication({ applicationId: command.applicationId }), - ); + const { principal, actor } = authorization; + const command = { ...input, applicationId: authorization.action.applicationId }; const digest = commandDigest({ schema: "DeleteTeamApplication/v1", @@ -644,12 +658,15 @@ export const deleteTeamApplication = ( }); export const reviseTeamApplicationIntake = ( - command: ReviseTeamApplicationIntakeCommand, - principal: TeamApplicationPrincipal, + authorization: TeamApplicationAuthorization< + Extract + >, + input: Omit, checkPrecondition: (current: TeamApplicationIntake) => Effect.Effect, ) => Effect.gen(function* () { - const actor = yield* authorizeIntakeRevision(principal, command.teamId); + const { principal, actor } = authorization; + const command = { ...input, teamId: authorization.action.teamId }; const digest = commandDigest({ schema: "ReviseTeamApplicationIntake/v1", diff --git a/packages/database/src/team-application/service.test.ts b/packages/database/src/team-application/service.test.ts index c66fab6d..2bdd34e9 100644 --- a/packages/database/src/team-application/service.test.ts +++ b/packages/database/src/team-application/service.test.ts @@ -8,6 +8,7 @@ import { TeamApplications, type TeamApplicationInput, type TeamApplicationIntake, + TeamApplicationAction, } from "@vektorprogrammet/domain/team-application"; import { DatabaseTestLive } from "../test-support/platform.js"; import { Database } from "../service.js"; @@ -359,10 +360,18 @@ layer(teamApplicationsLayer, { excludeTestServices: true, timeout: "30 seconds" Effect.flatMap(principal(person), (actor) => inTransaction( TeamApplications.use((service) => - service.deleteApplication( - { commandId: commandId(command), applicationId }, - actor, - ), + service + .authorize( + actor, + TeamApplicationAction.DeleteTeamApplication({ applicationId }), + ) + .pipe( + Effect.flatMap((authorization) => + service.deleteApplication(authorization, { + commandId: commandId(command), + }), + ), + ), ), ), ); @@ -422,16 +431,26 @@ layer(teamApplicationsLayer, { excludeTestServices: true, timeout: "30 seconds" Effect.flatMap(principal(person), (actor) => inTransaction( TeamApplications.use((service) => - service.reviseIntake( - { - commandId: commandId(command), - teamId: teamId("ta-revise"), - acceptApplication: false, - deadline: "2099-06-01T00:00:00+02:00", - }, - actor, - check, - ), + service + .authorize( + actor, + TeamApplicationAction.ReviseTeamApplicationIntake({ + teamId: teamId("ta-revise"), + }), + ) + .pipe( + Effect.flatMap((authorization) => + service.reviseIntake( + authorization, + { + commandId: commandId(command), + acceptApplication: false, + deadline: "2099-06-01T00:00:00+02:00", + }, + check, + ), + ), + ), ), ), ); diff --git a/packages/database/src/team-application/service.ts b/packages/database/src/team-application/service.ts index d7c7f88d..ee09920b 100644 --- a/packages/database/src/team-application/service.ts +++ b/packages/database/src/team-application/service.ts @@ -54,10 +54,12 @@ export const TeamApplicationsLive = (delivery: Partial - deleteTeamApplication(command, principal).pipe(Effect.provideService(Database, database)), - reviseIntake: (command, principal, checkPrecondition) => - reviseTeamApplicationIntake(command, principal, checkPrecondition).pipe( + deleteApplication: (authorization, command) => + deleteTeamApplication(authorization, command).pipe( + Effect.provideService(Database, database), + ), + reviseIntake: (authorization, command, checkPrecondition) => + reviseTeamApplicationIntake(authorization, command, checkPrecondition).pipe( Effect.provideService(Database, database), ), deliverNextOutboxEffect: (sender, idle) => diff --git a/packages/domain/src/placements/certificate.ts b/packages/domain/src/placements/certificate.ts index 89e5caa9..7a041772 100644 --- a/packages/domain/src/placements/certificate.ts +++ b/packages/domain/src/placements/certificate.ts @@ -3,7 +3,7 @@ * of the department whose confirmed total is above zero, with the confirmed schools and days, and * it names the issuer and the seat that authorizes them (docs/system.md#certificates). */ -import { Data, Option, Order, Schema } from "effect"; +import { Option, Order, Schema } from "effect"; import { CertificateIssuer } from "../organization/board-roster.js"; import { DepartmentId, PersonId, SemesterId } from "../organization/schema.js"; import { canonicalJsonBytes, sha256Hex } from "../shared-kernel/canonical-json.js"; @@ -204,13 +204,36 @@ export const CertificateAssistant = Schema.Struct({ export type CertificateAssistant = typeof CertificateAssistant.Type; -/** The command whose authority a request resolves before a stored response can replay. */ -export type CertificateCommandTarget = Data.TaggedEnum<{ - ConfirmDaysServed: { readonly departmentId: DepartmentId; readonly semesterId: SemesterId }; - IssueCertificate: { readonly departmentId: DepartmentId; readonly personId: PersonId }; -}>; +/** Type-only brands. Only the Placements adapter that resolves current authority builds these. */ +declare const DaysServedConfirmationAuthorizationBrand: unique symbol; -export const CertificateCommandTarget = Data.taggedEnum(); +declare const CertificateIssueAuthorizationBrand: unique symbol; + +/** + * Proof that the principal holds `placements.days-served` in one department and semester, resolved + * under the department lock inside the command's transaction. `confirmDaysServed` confirms only in + * this scope and resolves no authority again. Valid only in the transaction that resolved it. + */ +export interface DaysServedConfirmationAuthorization { + readonly [DaysServedConfirmationAuthorizationBrand]: "DaysServedConfirmationAuthorization"; + readonly principal: CertificatePrincipal; + readonly departmentId: DepartmentId; + readonly semesterId: SemesterId; +} + +/** + * Proof that the principal currently issues the department's certificates, under the seat named by + * `issuer`, for one other person, resolved under the department lock inside the command's + * transaction. `issueCertificate` issues only this certificate and resolves no authority again. + * Valid only in the transaction that resolved it. + */ +export interface CertificateIssueAuthorization { + readonly [CertificateIssueAuthorizationBrand]: "CertificateIssueAuthorization"; + readonly principal: CertificatePrincipal; + readonly department: { readonly departmentId: DepartmentId; readonly name: string }; + readonly personId: PersonId; + readonly issuer: CertificateIssuer; +} /** A semester with its bounds, as the scope choice and the days-served list name it. */ export const CertificateSemesterScope = Schema.Struct({ diff --git a/packages/domain/src/placements/service.ts b/packages/domain/src/placements/service.ts index 8ec068f0..a28eae88 100644 --- a/packages/domain/src/placements/service.ts +++ b/packages/domain/src/placements/service.ts @@ -2,13 +2,14 @@ import { Context, Data, type Effect } from "effect"; import type { DepartmentId, OrganizationPersonAuthority, PersonId } from "../organization/index.js"; import type { DepartmentReach } from "../authz/reach.js"; import type { - CertificateCommandTarget, CertificateAssistant, + CertificateIssueAuthorization, CertificateIssue, CertificatePreview, CertificatePrincipal, CertificateScopes, CertificateSemesterScope, + DaysServedConfirmationAuthorization, IssueCertificateCommand, } from "./certificate.js"; import type { @@ -168,21 +169,22 @@ export interface PlacementsOperations { cursor?: string, ) => Effect.Effect; /** - * Resolves the principal's current authority for one days-served or certificate command on the - * caller's transaction, with the command's locks, before a stored response can replay. + * Resolves the principal's current `placements.days-served` authority in one department and + * semester on the caller's transaction, under the department lock, before a stored response can + * replay. */ - readonly authorizeCertificateCommand: ( + readonly authorizeDaysServedConfirmation: ( principal: CertificatePrincipal, - target: CertificateCommandTarget, - ) => Effect.Effect; + scope: PlacementScope, + ) => Effect.Effect; /** - * Appends the next confirmation of one assistant's total under the department lock, after the + * Appends the next confirmation of one assistant's total in the authorized scope, after the * transport precondition on the fresh entry. Never changes an earlier confirmation or a service * fact. The callback grants no authority and must not write business state. */ readonly confirmDaysServed: ( - principal: CertificatePrincipal, - command: ConfirmDaysServedCommand, + authorization: DaysServedConfirmationAuthorization, + command: Omit, checkPrecondition: (current: DaysServedEntry) => Effect.Effect, ) => Effect.Effect; /** One bounded page of the assistants with service facts in a department; issuers only. */ @@ -198,12 +200,22 @@ export interface PlacementsOperations { personId: PersonId, ) => Effect.Effect; /** - * Records one issue of the current certificate under the department lock, after the transport - * precondition on the fresh preview: who, when, the authorizing seat, and the content hash. + * Resolves whether the principal currently issues the department's certificates for another + * person, on the caller's transaction and under the department lock, before a stored response + * can replay. */ - readonly issueCertificate: ( + readonly authorizeCertificateIssue: ( principal: CertificatePrincipal, - command: IssueCertificateCommand, + departmentId: DepartmentId, + personId: PersonId, + ) => Effect.Effect; + /** + * Records one issue of the authorized certificate, after the transport precondition on the + * fresh preview: who, when, the authorizing seat, and the content hash. + */ + readonly issueCertificate: ( + authorization: CertificateIssueAuthorization, + command: Pick, checkPrecondition: (current: CertificatePreview) => Effect.Effect, ) => Effect.Effect; } diff --git a/packages/domain/src/recruitment/maintenance.ts b/packages/domain/src/recruitment/maintenance.ts index 0478847f..f784dd88 100644 --- a/packages/domain/src/recruitment/maintenance.ts +++ b/packages/domain/src/recruitment/maintenance.ts @@ -45,6 +45,21 @@ export const RecruitmentMaintenanceCommand = Schema.TaggedUnion({ export type RecruitmentMaintenanceCommand = typeof RecruitmentMaintenanceCommand.Type; +/** Type-only brand. Only the Recruitment adapter that resolves current authority builds this evidence. */ +declare const RecruitmentMaintenanceAuthorizationBrand: unique symbol; + +/** + * Proof that a person may run one maintenance command, resolved with its locks inside the + * command's transaction: a questionnaire change needs an active global administrator, a staffing + * change `recruitment.interviews` over the interview's department. The command runs from it, and + * `maintainRecruitment` resolves no authority again. Valid only in the transaction that resolved it. + */ +export interface RecruitmentMaintenanceAuthorization { + readonly [RecruitmentMaintenanceAuthorizationBrand]: "RecruitmentMaintenanceAuthorization"; + readonly personId: PersonId; + readonly command: RecruitmentMaintenanceCommand; +} + export const RecruitmentMaintenanceResult = Schema.TaggedUnion({ QuestionnaireSaved: { interviewSchemaId: InterviewSchemaId, revision: Revision }, InterviewStaffingChanged: { interviewId: RecruitmentInterviewId, revision: Revision }, diff --git a/packages/domain/src/recruitment/service.ts b/packages/domain/src/recruitment/service.ts index 86f28beb..87e756d8 100644 --- a/packages/domain/src/recruitment/service.ts +++ b/packages/domain/src/recruitment/service.ts @@ -16,6 +16,7 @@ import type { RecruitmentInvitationTransition, } from "./access.js"; import type { + RecruitmentMaintenanceAuthorization, RecruitmentMaintenanceCommand, RecruitmentMaintenanceResult, QuestionnaireManagement, @@ -179,13 +180,14 @@ export interface RecruitmentOperations { readonly readInterviewStaffing: ( personId: PersonId, ) => Effect.Effect; + /** Resolves current authority for one command, with its locks, on the caller's transaction. */ readonly authorizeMaintenance: ( command: RecruitmentMaintenanceCommand, personId: PersonId, - ) => Effect.Effect; + ) => Effect.Effect; + /** Runs the authorized command in the transaction that authorized it. */ readonly maintainRecruitment: ( - command: RecruitmentMaintenanceCommand, - personId: PersonId, + authorization: RecruitmentMaintenanceAuthorization, ) => Effect.Effect; readonly readAssignmentBoard: ( query: RecruitmentAssignmentBoardQuery, diff --git a/packages/domain/src/schools/administration.ts b/packages/domain/src/schools/administration.ts index 7f767421..403c97e6 100644 --- a/packages/domain/src/schools/administration.ts +++ b/packages/domain/src/schools/administration.ts @@ -2,7 +2,13 @@ import { Schema } from "effect"; import { reachedDepartments, ReachedDepartments } from "../authz/reach.js"; import { DepartmentId, PersonId, SemesterId } from "../organization/schema.js"; import type { OrganizationPersonAuthority } from "../organization/authority.js"; -import { School, SchoolCapacityPlan, SchoolId, SchoolDirectoryDepartmentSchema } from "./schema.js"; +import { + School, + SchoolCapacityPlan, + SchoolId, + SchoolDirectoryDepartmentSchema, + type SchoolJson, +} from "./schema.js"; const Reason = Schema.String.check( Schema.makeFilter((value) => value.trim().length > 0), @@ -64,6 +70,24 @@ export const SchoolCommand = Schema.TaggedUnion({ export type SchoolCommand = typeof SchoolCommand.Type; +/** Type-only brand. Only the Schools adapter that resolves current authority builds this evidence. */ +declare const SchoolCommandAuthorizationBrand: unique symbol; + +/** + * Proof that a person may run one school command, resolved with its locks inside the command's + * transaction. The command runs from it: `executeCommand` takes no other command or person, and + * resolves no authority again. Valid only in the transaction that resolved it. + */ +export interface SchoolCommandAuthorization { + readonly [SchoolCommandAuthorizationBrand]: "SchoolCommandAuthorization"; + readonly personId: PersonId; + readonly command: SchoolCommand; + /** The locked school that the command changes; null for `CreateSchool`. */ + readonly school: SchoolJson | null; + /** The departments whose administration authorized the command. */ + readonly departments: ReadonlyArray; +} + export const SchoolCommandResult = Schema.Struct({ schoolId: SchoolId, capacityId: Schema.NullOr(SchoolCapacityPlan.fields.capacityId), diff --git a/packages/domain/src/schools/service.ts b/packages/domain/src/schools/service.ts index 6d7923fc..f5ca146f 100644 --- a/packages/domain/src/schools/service.ts +++ b/packages/domain/src/schools/service.ts @@ -4,6 +4,7 @@ import type { SchoolDirectory, SchoolDirectoryListInput } from "./schema.js"; import type { PersonId } from "../organization/schema.js"; import type { SchoolCommand, + SchoolCommandAuthorization, SchoolCommandResult, SchoolManagement, SchoolCommandFailure, @@ -13,13 +14,14 @@ export interface SchoolsOperations { readonly readManagement: ( personId: PersonId, ) => Effect.Effect; + /** Resolves current authority for one command, with its locks, on the caller's transaction. */ readonly authorizeCommand: ( command: SchoolCommand, personId: PersonId, - ) => Effect.Effect; + ) => Effect.Effect; + /** Runs the authorized command in the transaction that authorized it. */ readonly executeCommand: ( - command: SchoolCommand, - personId: PersonId, + authorization: SchoolCommandAuthorization, ) => Effect.Effect; readonly listDirectory: ( input: SchoolDirectoryListInput, diff --git a/packages/domain/src/team-application/service.ts b/packages/domain/src/team-application/service.ts index 644d7247..1793baf5 100644 --- a/packages/domain/src/team-application/service.ts +++ b/packages/domain/src/team-application/service.ts @@ -43,6 +43,31 @@ export type TeamApplicationAction = Data.TaggedEnum<{ export const TeamApplicationAction = Data.taggedEnum(); +/** Type-only brand. Only the TeamApplications adapter that resolves current authority builds this. */ +declare const TeamApplicationAuthorizationBrand: unique symbol; + +/** + * Proof that a principal may take one staff action, resolved with its row locks inside the + * action's transaction. A change command runs from it: it takes its application or team from + * `action` and resolves no authority again. Valid only in the transaction that resolved it. + */ +export interface TeamApplicationAuthorization { + readonly [TeamApplicationAuthorizationBrand]: A["_tag"]; + readonly principal: TeamApplicationPrincipal; + readonly action: A; + readonly actor: TeamApplicationActor; +} + +type DeleteTeamApplicationAction = Extract< + TeamApplicationAction, + { readonly _tag: "DeleteTeamApplication" } +>; + +type ReviseTeamApplicationIntakeAction = Extract< + TeamApplicationAction, + { readonly _tag: "ReviseTeamApplicationIntake" } +>; + export interface TeamApplicationSubmission { readonly confirmation: TeamApplicationConfirmation; readonly replayed: boolean; @@ -95,11 +120,11 @@ export interface TeamApplicationsOperations { * Change actions lock the target row first. The result grants nothing beyond * the caller's transaction. */ - readonly authorize: ( + readonly authorize: ( principal: TeamApplicationPrincipal, - action: TeamApplicationAction, + action: A, ) => Effect.Effect< - TeamApplicationActor, + TeamApplicationAuthorization, TeamApplicationAccessDenied | TeamApplicationNotFound | TeamApplicationPersistenceError >; readonly listApplications: ( @@ -116,8 +141,8 @@ export interface TeamApplicationsOperations { * clears every undelivered notification envelope for it. */ readonly deleteApplication: ( - command: DeleteTeamApplicationCommand, - principal: TeamApplicationPrincipal, + authorization: TeamApplicationAuthorization, + command: Pick, ) => Effect.Effect; /** * Leader-only. Locks the team, supplies the fresh intake to the transport @@ -125,8 +150,8 @@ export interface TeamApplicationsOperations { * authority and must not write business state. */ readonly reviseIntake: ( - command: ReviseTeamApplicationIntakeCommand, - principal: TeamApplicationPrincipal, + authorization: TeamApplicationAuthorization, + command: Omit, checkPrecondition: (current: TeamApplicationIntake) => Effect.Effect, ) => Effect.Effect; /** From b8c062931a83fdf2b10d7b1ca7edd523f0ac9160 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:35:53 +0000 Subject: [PATCH 5/9] refactor(organization): require a team interest read scope listTeamInterestRegistrations took a filter of authorized departments and teams that its HTTP caller computed, so it read whatever scope a caller named. requireTeamInterestScope now computes that scope from the resolved authority, as the handler did, and is the only constructor of TeamInterestReadScope, which the listing requires. The unused departmentId filter is gone; the semester stays an argument. A property test checks that the scope holds only departments and teams that team-interest.read reaches, inside the requested department, and that it denies exactly where nothing is readable. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/backend/src/organization/http.ts | 50 +++----- .../organization/team-interest.http.test.ts | 33 ++---- docs/constructs.md | 1 + docs/constructs/authority-evidence.md | 43 ++++++- packages/database/src/database.test.ts | 34 ++++-- .../src/organization/postgres-layer.ts | 4 +- .../database/src/organization/postgres.ts | 29 +++-- .../domain/src/authz/reach-evidence.test.ts | 72 ++++++++++++ packages/domain/src/organization/AGENTS.md | 1 + packages/domain/src/organization/authority.ts | 109 +++++++++++++++++- packages/domain/src/organization/service.ts | 12 +- 11 files changed, 290 insertions(+), 98 deletions(-) diff --git a/apps/backend/src/organization/http.ts b/apps/backend/src/organization/http.ts index bef0b46b..2871d8af 100644 --- a/apps/backend/src/organization/http.ts +++ b/apps/backend/src/organization/http.ts @@ -2,7 +2,6 @@ import type { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-pe import { reachedDepartments, ReachedDepartments, - reachedTeams, ResourceId, ResourceKind, Scope, @@ -21,6 +20,7 @@ import { OrganizationCommandId, OrganizationLifecycleCommand, mapOrganizationAuthorityToOrganizationActor, + requireTeamInterestScope, requireOrganizationAdministrator, SemesterId, type TeamId, @@ -28,7 +28,6 @@ import { type OrganizationCommandFailure, type OrganizationLifecycleFailure, type OrganizationPersonAuthority, - type TeamInterestFilter, } from "@vektorprogrammet/domain/organization"; import type { Identity, IdentityEngineError } from "@vektorprogrammet/domain/identity"; import type { ProfileFailure } from "@vektorprogrammet/domain/profile"; @@ -796,57 +795,34 @@ const listTeamInterest = (request: Request, input: OrganizationApiHttpOptions) = return Effect.gen(function* () { const authority = yield* input.resolveAuthority(request); const requested = yield* optionalQueryIdentity(request, "department", DepartmentId); + const departments = yield* Organization.use(({ listDepartments }) => listDepartments); + // An authenticated caller without team-interest reach receives a typed denial, never an // empty success (spec 0059 authorization boundary). A team's current leader reads the // registrations of that team; department reach reads the whole department. - const scope = yield* authorizedDepartmentScope(authority, "team-interest.read"); - const departmentScope = scope.departmentIds; - - const teamScope = reachedTeams(authority, "team-interest.read").flatMap((teamId) => { - const membership = authority.memberships.find((entry) => entry.teamId === teamId); - - return membership === undefined ? [] : [{ teamId, departmentId: membership.departmentId }]; - }); - - if (!scope.organizationWide && departmentScope.length === 0 && teamScope.length === 0) { - return yield* Problem.make("authority.denied"); - } - - const authorizedTeams = teamScope.filter( - (team) => - (requested === undefined || team.departmentId === requested) && - !departmentScope.includes(team.departmentId), - ); - - const authorized = - requested === undefined - ? departmentScope - : departmentScope.includes(requested) - ? [requested] - : authorizedTeams.length > 0 - ? [] - : yield* Problem.make("authority.denied"); + const scope = yield* Effect.fromResult( + requireTeamInterestScope(authority, { + requested, + departments: departments.map((department) => department.departmentId), + }), + ).pipe(Effect.mapError(() => Problem.make("authority.denied"))); yield* authorizeOrganizationCollection({ request, authority, endpoint: ListTeamInterestEndpoint, - departmentIds: authorized, - teams: authorizedTeams, + departmentIds: scope.departmentIds, + teams: scope.teams, presentation, }); // Unknown department reference denies with 422 before any data leaves the store. if (requested !== undefined) yield* assertDepartmentsExist([requested]); - const filter: TeamInterestFilter = { - authorizedDepartmentIds: authorized, - authorizedTeamIds: authorizedTeams.map(({ teamId }) => teamId), - semesterId: yield* optionalQueryIdentity(request, "semester", SemesterId), - }; + const semesterId = yield* optionalQueryIdentity(request, "semester", SemesterId); const rows = yield* Organization.use(({ listTeamInterestRegistrations }) => - listTeamInterestRegistrations(filter), + listTeamInterestRegistrations(scope, semesterId), ); return yield* privateReadJson(TeamInterestResponse)({ diff --git a/apps/backend/src/organization/team-interest.http.test.ts b/apps/backend/src/organization/team-interest.http.test.ts index b5376d29..09a8b826 100644 --- a/apps/backend/src/organization/team-interest.http.test.ts +++ b/apps/backend/src/organization/team-interest.http.test.ts @@ -15,6 +15,7 @@ import { Organization, PersonId, type OrganizationOperations, + type TeamInterestReadScope, } from "@vektorprogrammet/domain/organization"; import { DateTime, Effect, Layer, Schema } from "effect"; import { describe, expect, it } from "@effect/vitest"; @@ -99,11 +100,8 @@ const registrationRows = [ }, ] as const; -let lastTeamInterestFilter: { - authorizedDepartmentIds: ReadonlyArray; - authorizedTeamIds: ReadonlyArray; - semesterId?: string; -}; +// The scope that the handler required on the last listing. +let lastTeamInterestScope: TeamInterestReadScope | undefined; // The departments that the organization store holds; a test may empty it. let storedDepartments = [department, secondDepartment]; @@ -112,20 +110,16 @@ const organization = { listDepartments: Effect.sync(() => storedDepartments), listTeams: () => Effect.succeed([]), listFieldOfStudies: Effect.succeed([]), - listTeamInterestRegistrations: (filter: { - authorizedDepartmentIds: ReadonlyArray; - authorizedTeamIds: ReadonlyArray; - semesterId?: string; - }) => + listTeamInterestRegistrations: (scope: TeamInterestReadScope, semesterId?: SemesterId) => Effect.sync(() => { - lastTeamInterestFilter = filter; + lastTeamInterestScope = scope; const rows = registrationRows .filter( (row) => - (filter.authorizedDepartmentIds.includes(row.departmentId) || - filter.authorizedTeamIds.includes(row.teamId)) && - (filter.semesterId === undefined || row.semesterId === filter.semesterId), + (scope.departmentIds.includes(row.departmentId) || + scope.teams.some((team) => team.teamId === row.teamId)) && + (semesterId === undefined || row.semesterId === semesterId), ) .toSorted((left, right) => left.registrationId - right.registrationId); @@ -359,7 +353,7 @@ describe("spec 0059 team-interest HTTP boundary", () => { "hydra:totalItems": 2, }); // Rows ordered registration_id ASC regardless of insert order. - expect(lastTeamInterestFilter.authorizedDepartmentIds).toEqual(["department-1"]); + expect(lastTeamInterestScope?.departmentIds).toEqual(["department-1"]); }), ); @@ -371,8 +365,8 @@ describe("spec 0059 team-interest HTTP boundary", () => { "hydra:member": [{ id: 3, userName: "User C", teamName: "Team Two" }], "hydra:totalItems": 1, }); - expect(lastTeamInterestFilter.authorizedDepartmentIds).toEqual([]); - expect(lastTeamInterestFilter.authorizedTeamIds).toEqual(["team-2"]); + expect(lastTeamInterestScope?.departmentIds).toEqual([]); + expect(lastTeamInterestScope?.teams.map(({ teamId }) => teamId)).toEqual(["team-2"]); const otherDepartment = yield* get( "/api/team-interest-registrations?department=department-1", @@ -397,10 +391,7 @@ describe("spec 0059 team-interest HTTP boundary", () => { ], "hydra:totalItems": 3, }); - expect(lastTeamInterestFilter.authorizedDepartmentIds).toEqual([ - "department-1", - "department-2", - ]); + expect(lastTeamInterestScope?.departmentIds).toEqual(["department-1", "department-2"]); }), ); diff --git a/docs/constructs.md b/docs/constructs.md index ce7b4080..cf75a186 100644 --- a/docs/constructs.md +++ b/docs/constructs.md @@ -20,6 +20,7 @@ Tag a construct only when at least 2 modules outside its own module and the test - [authority-evidence](constructs/authority-evidence.md): Checks a resolved authority and returns the evidence that a command which needs that authority takes; nothing else constructs it. - [`requireDepartmentReach`](constructs/authority-evidence.md#requiredepartmentreach): Checks that a resolved authority reaches one department with a capability, and returns the evidence that a department-scoped command requires. - [`requireOrganizationAdministrator`](constructs/authority-evidence.md#requireorganizationadministrator): Checks that a resolved authority holds active global administration, and returns the evidence that the Organization administration commands require. + - [`requireTeamInterestScope`](constructs/authority-evidence.md#requireteaminterestscope): Checks what team interest a resolved authority may read, narrowed to one requested department, and returns the scope that the listing requires. - [http-problem](constructs/http-problem.md): Answers a native HTTP request with a declared problem: failure mapping, credential classification, authorization, and decoding. - [`authorizeAdmissionPerson`](constructs/http-problem.md#authorizeadmissionperson): Evaluates one admission person AccessSpec. - [`returningAuthorization`](constructs/http-problem.md#returningauthorization): Resolves the current person and authorizes one returning-assistant operation on that person's own profile. diff --git a/docs/constructs/authority-evidence.md b/docs/constructs/authority-evidence.md index c1c6df63..13f711d2 100644 --- a/docs/constructs/authority-evidence.md +++ b/docs/constructs/authority-evidence.md @@ -63,7 +63,7 @@ requireOrganizationAdministrator( - Errors: `OrganizationRoleDenied` - Requirements: none - Side effects: none -- Source: [packages/domain/src/organization/authority.ts:263](../../packages/domain/src/organization/authority.ts#L263) +- Source: [packages/domain/src/organization/authority.ts:271](../../packages/domain/src/organization/authority.ts#L271) **How it works** @@ -84,3 +84,44 @@ yield* organization.createDepartment(command, administrator); Passing an `OrganizationActor` to a create command, or checking its tag at the call site: any module can build an actor, so the command could not trust it. Resolve the authority and require the evidence here. + +## `requireTeamInterestScope` + +Checks what team interest a resolved authority may read, narrowed to one requested department, and returns the scope that the listing requires. + +```ts +requireTeamInterestScope( + authority: OrganizationPersonAuthority, + input: { readonly requested: DepartmentId | undefined; readonly departments: ReadonlyArray } +): Result.Result +``` + +- Inputs: + - `authority: OrganizationPersonAuthority` + - `input: { readonly requested: DepartmentId | undefined; readonly departments: ReadonlyArray }` +- Output: `Result.Result` +- Errors: `TeamInterestScopeDenied` +- Requirements: none +- Side effects: none +- Source: [packages/domain/src/organization/authority.ts:366](../../packages/domain/src/organization/authority.ts#L366) + +**How it works** + +It is the only constructor of `TeamInterestReadScope`. A reach over the organization +reads every department in `departments`, also while there is none; a department reach reads +that department; a team leader's reach reads the team where no department reach covers it. A +person with no reach, or with none in the requested department or its teams, is denied. + +**Use** + +```ts +const scope = yield* Effect.fromResult( + requireTeamInterestScope(authority, { requested, departments }), +); +yield* organization.listTeamInterestRegistrations(scope, semesterId); +``` + +**Avoid** + +Computing the departments and teams in a handler and passing them to the listing as a +filter: the listing then reads whatever scope a caller names. Require the scope here. diff --git a/packages/database/src/database.test.ts b/packages/database/src/database.test.ts index c1c14584..b1e4e88c 100644 --- a/packages/database/src/database.test.ts +++ b/packages/database/src/database.test.ts @@ -42,7 +42,9 @@ import { OrganizationCommandId, OrganizationPersonAuthoritySchema, requireOrganizationAdministrator, + requireTeamInterestScope, } from "@vektorprogrammet/domain/organization"; +import { activeAdministratorAuthority } from "@vektorprogrammet/domain/organization/authority-fixtures"; import { RecruitmentAssignmentObservationSchema, RecruitmentScheduleObservationSchema, @@ -93,7 +95,7 @@ import { listStaleReceiptOutboxClaimIds, recoverStaleReceiptOutbox, } from "./receipt/outbox.js"; -import { Context, Match, Predicate, Effect, FileSystem, Layer, Path } from "effect"; +import { Context, Match, Predicate, Effect, FileSystem, Layer, Path, Result } from "effect"; import { DatabaseTestLive, TestPlatform } from "./test-support/platform.js"; import { databaseMigrationDefinitions, @@ -4084,17 +4086,27 @@ describe("DatabaseTest", () => { ) `; - const authorizedRows = yield* listOrganizationTeamInterestRegistrations({ - authorizedDepartmentIds: [departmentA, departmentB], - authorizedTeamIds: [], - }); + // An organization-wide reader, narrowed to one department for the filtered read. + const reader = activeAdministratorAuthority("team-interest-scope-reader"); - const filteredRows = yield* listOrganizationTeamInterestRegistrations({ - authorizedDepartmentIds: [departmentA, departmentB], - authorizedTeamIds: [], - departmentId: departmentB, - semesterId: SemesterId.make("semester-scope"), - }); + const authorizedRows = yield* listOrganizationTeamInterestRegistrations( + Result.getOrThrow( + requireTeamInterestScope(reader, { + requested: undefined, + departments: [departmentA, departmentB], + }), + ), + ); + + const filteredRows = yield* listOrganizationTeamInterestRegistrations( + Result.getOrThrow( + requireTeamInterestScope(reader, { + requested: departmentB, + departments: [departmentA, departmentB], + }), + ), + SemesterId.make("semester-scope"), + ); return { authorizedRows, filteredRows }; }); diff --git a/packages/database/src/organization/postgres-layer.ts b/packages/database/src/organization/postgres-layer.ts index af28d44d..8473dd88 100644 --- a/packages/database/src/organization/postgres-layer.ts +++ b/packages/database/src/organization/postgres-layer.ts @@ -76,8 +76,8 @@ export const OrganizationLive = Layer.effect( listHistoricalMemberships: listOrganizationHistoricalMemberships.pipe( Effect.provideService(Database, database), ), - listTeamInterestRegistrations: (filter) => - listOrganizationTeamInterestRegistrations(filter).pipe( + listTeamInterestRegistrations: (scope, semesterId) => + listOrganizationTeamInterestRegistrations(scope, semesterId).pipe( Effect.provideService(Database, database), ), projectMailingLists: (input) => diff --git a/packages/database/src/organization/postgres.ts b/packages/database/src/organization/postgres.ts index 4f10ef34..91262c2d 100644 --- a/packages/database/src/organization/postgres.ts +++ b/packages/database/src/organization/postgres.ts @@ -25,7 +25,7 @@ import { type DepartmentId, type TeamId, } from "@vektorprogrammet/domain/organization"; -import type { TeamInterestFilter } from "@vektorprogrammet/domain/organization"; +import type { SemesterId, TeamInterestReadScope } from "@vektorprogrammet/domain/organization"; import { importLegacyOrganizationEffect, type LegacyOrganizationSnapshot, @@ -600,40 +600,37 @@ const decodeTeamInterestRegistration = flow( ), ); -const teamInterestScopeClause = (database: DatabaseOperations, filter: TeamInterestFilter) => +const teamInterestScopeClause = (database: DatabaseOperations, scope: TeamInterestReadScope) => Statement.or([ - ...filter.authorizedDepartmentIds.map( + ...scope.departmentIds.map( (departmentId) => database`registration.department_id = ${departmentId}`, ), - ...filter.authorizedTeamIds.map((teamId) => database`registration.team_id = ${teamId}`), + ...scope.teams.map(({ teamId }) => database`registration.team_id = ${teamId}`), ]); const teamInterestPredicate = ( database: DatabaseOperations, - filter: TeamInterestFilter, + scope: TeamInterestReadScope, + semesterId: SemesterId | undefined, ): Statement.Fragment => { - const clauses: Array = [teamInterestScopeClause(database, filter)]; + const clauses: Array = [teamInterestScopeClause(database, scope)]; - if (filter.semesterId !== undefined) { - clauses.push(database`registration.semester_id = ${filter.semesterId}`); - } - - if (filter.departmentId !== undefined) { - clauses.push(database`registration.department_id = ${filter.departmentId}`); + if (semesterId !== undefined) { + clauses.push(database`registration.semester_id = ${semesterId}`); } return Statement.and(clauses); }; export const listOrganizationTeamInterestRegistrations = ( - filter: TeamInterestFilter, + scope: TeamInterestReadScope, + semesterId?: SemesterId, ): Effect.Effect< ReadonlyArray, OrganizationDecodeError | OrganizationPersistenceError, Database > => { - if (filter.authorizedDepartmentIds.length === 0 && filter.authorizedTeamIds.length === 0) - return Effect.succeed([]); + if (scope.departmentIds.length === 0 && scope.teams.length === 0) return Effect.succeed([]); return Effect.gen(function* () { const database = yield* Database; @@ -653,7 +650,7 @@ export const listOrganizationTeamInterestRegistrations = ( FROM public.organization_team_interest_registrations AS registration INNER JOIN organization_teams AS team ON team.team_id = registration.team_id - WHERE ${teamInterestPredicate(database, filter)} + WHERE ${teamInterestPredicate(database, scope, semesterId)} ORDER BY registration.registration_id ASC `.pipe( Effect.catchTag("SqlError", (cause) => diff --git a/packages/domain/src/authz/reach-evidence.test.ts b/packages/domain/src/authz/reach-evidence.test.ts index ad355ef6..8bd3e57c 100644 --- a/packages/domain/src/authz/reach-evidence.test.ts +++ b/packages/domain/src/authz/reach-evidence.test.ts @@ -4,6 +4,8 @@ import { Arbitrary } from "effect/unstable/arbitrary"; import { admissionOutcomePermission } from "../admissions/outcome.js"; import { mapOrganizationAuthorityToDepartmentActor, + requireTeamInterestScope, + type TeamInterestReadScope, OrganizationAuthorityBoardSeatSchema, OrganizationAuthorityMembershipSchema, OrganizationGlobalAdministratorStatusSchema, @@ -12,6 +14,7 @@ import { import { spec0055OrganizationAuthorityFixtures } from "../organization/authority-fixtures.test-support.js"; import { DepartmentId, TeamId } from "../organization/schema.js"; import { canManagePlacements } from "../placements/policy.js"; +import type { OrganizationOperations } from "../organization/service.js"; import type { PlacementExecution } from "../placements/service.js"; import type { AdmissionsOperations } from "../admissions/service.js"; import { @@ -25,6 +28,9 @@ import { import { type DepartmentReach, DepartmentReachDenied, + ReachedDepartments, + reachedDepartments, + reachedTeams, reaches, ReachTarget, requireDepartmentReach, @@ -200,6 +206,62 @@ it.prop( propertyOptions, ); +const requested = Arbitrary.map( + Arbitrary.schema(Schema.Literals(["none", ...departments])), + (id) => (id === "none" ? undefined : DepartmentId.make(id)), +); + +it.prop( + "requireTeamInterestScope reads only what team-interest.read reaches, inside the requested department", + { authority, requested }, + ({ authority, requested }) => { + const known = departments.map((id) => DepartmentId.make(id)); + const result = requireTeamInterestScope(authority, { requested, departments: known }); + const reached = reachedDepartments(authority, "team-interest.read"); + const readable = ReachedDepartments.$is("All")(reached) ? known : reached.departmentIds; + const teams = reachedTeams(authority, "team-interest.read"); + + // A reached team is read in the department of the person's appointment to it. + const teamDepartments = teams.flatMap((teamId) => { + const membership = authority.memberships.find((entry) => entry.teamId === teamId); + + return membership === undefined ? [] : [membership.departmentId]; + }); + + const organizationWide = ReachedDepartments.$is("All")(reached); + + const readableHere = + requested === undefined + ? organizationWide || readable.length > 0 || teamDepartments.length > 0 + : readable.includes(requested) || teamDepartments.includes(requested); + + expect(Result.isSuccess(result)).toBe(readableHere); + + if (Result.isFailure(result)) { + expect(result.failure.personId).toBe(authority.personId); + + return; + } + + const scope = result.success; + expect(scope.personId).toBe(authority.personId); + + for (const departmentId of scope.departmentIds) { + expect(readable).toContain(departmentId); + + if (requested !== undefined) expect(departmentId).toBe(requested); + } + + for (const team of scope.teams) { + expect(teams).toContain(team.teamId); + expect(scope.departmentIds).not.toContain(team.departmentId); + + if (requested !== undefined) expect(team.departmentId).toBe(requested); + } + }, + propertyOptions, +); + it("requireDepartmentReach evidence is the only way to satisfy a department-scoped command", () => { type OutcomeDecider = Parameters[0]["decider"]; @@ -218,6 +280,16 @@ it("requireDepartmentReach evidence is the only way to satisfy a department-scop expectTypeOf>().not.toExtend(); expectTypeOf>().not.toExtend(); + // A handler-built filter is not a team-interest scope. + expectTypeOf<{ + readonly personId: TeamInterestReadScope["personId"]; + readonly departmentIds: TeamInterestReadScope["departmentIds"]; + readonly teams: TeamInterestReadScope["teams"]; + }>().not.toExtend(); + expectTypeOf< + Parameters[0] + >().toEqualTypeOf(); + // A board or coverage-board change cannot run on a person's own actor id. expectTypeOf<{ readonly mutation: Extract["mutation"]; diff --git a/packages/domain/src/organization/AGENTS.md b/packages/domain/src/organization/AGENTS.md index e1dd63a0..12a2b9bb 100644 --- a/packages/domain/src/organization/AGENTS.md +++ b/packages/domain/src/organization/AGENTS.md @@ -85,6 +85,7 @@ Responsibilities: The shared constructs defined here. Each name links to its contract; [docs/constructs.md](../../../../docs/constructs.md) indexes them all. - [`requireOrganizationAdministrator`](../../../../docs/constructs/authority-evidence.md#requireorganizationadministrator) (authority-evidence): Checks that a resolved authority holds active global administration, and returns the evidence that the Organization administration commands require. +- [`requireTeamInterestScope`](../../../../docs/constructs/authority-evidence.md#requireteaminterestscope) (authority-evidence): Checks what team interest a resolved authority may read, narrowed to one requested department, and returns the scope that the listing requires. Local invariants, pitfalls, and recipes go below this generated part; `just guides write` keeps them. diff --git a/packages/domain/src/organization/authority.ts b/packages/domain/src/organization/authority.ts index a9c4b1e1..60451fcc 100644 --- a/packages/domain/src/organization/authority.ts +++ b/packages/domain/src/organization/authority.ts @@ -1,4 +1,4 @@ -import { Result, Schema } from "effect"; +import { Data, Result, Schema } from "effect"; import { type AdmissionPeriodActor, AdmissionPeriodActorSchema, @@ -10,7 +10,15 @@ import { TeamScope, UnitKind, } from "../authz/delegation.js"; -import { holdsDepartmentReach, leadsAnyTeam, reaches, ReachTarget } from "../authz/reach.js"; +import { + holdsDepartmentReach, + leadsAnyTeam, + ReachedDepartments, + reachedDepartments, + reachedTeams, + reaches, + ReachTarget, +} from "../authz/reach.js"; import { compareRfc3339Instants, Rfc3339InstantSchema } from "../time.js"; import { OrganizationMemberSchema, @@ -309,3 +317,100 @@ export const mapOrganizationAuthorityToProfileRole = ( : "AuthorityInactive", ); }; + +/** Type-only brand. Only {@link requireTeamInterestScope} builds this evidence. */ +declare const TeamInterestReadScopeBrand: unique symbol; + +/** + * The registrations that a person may read now: whole departments that `team-interest.read` + * reaches, and the teams that it reaches outside them. The listing reads nothing else. + */ +export interface TeamInterestReadScope { + readonly [TeamInterestReadScopeBrand]: "TeamInterestReadScope"; + readonly personId: PersonId; + readonly departmentIds: ReadonlyArray; + readonly teams: ReadonlyArray<{ readonly teamId: TeamId; readonly departmentId: DepartmentId }>; +} + +/** The person reads no team interest, or none in the requested department. */ +export class TeamInterestScopeDenied extends Data.TaggedError("TeamInterestScopeDenied")<{ + readonly personId: PersonId; + readonly departmentId: DepartmentId | undefined; +}> {} + +/** + * Checks what team interest a resolved authority may read, narrowed to one requested department, + * and returns the scope that the listing requires. + * + * @remarks + * It is the only constructor of {@link TeamInterestReadScope}. A reach over the organization + * reads every department in `departments`, also while there is none; a department reach reads + * that department; a team leader's reach reads the team where no department reach covers it. A + * person with no reach, or with none in the requested department or its teams, is denied. + * + * @sideEffects none + * + * @example + * ```ts + * const scope = yield* Effect.fromResult( + * requireTeamInterestScope(authority, { requested, departments }), + * ); + * yield* organization.listTeamInterestRegistrations(scope, semesterId); + * ``` + * + * @avoid Computing the departments and teams in a handler and passing them to the listing as a + * filter: the listing then reads whatever scope a caller names. Require the scope here. + * + * @construct authority-evidence + */ +export const requireTeamInterestScope = ( + /** The person's authority, resolved from current facts at the request's instant. */ + authority: OrganizationPersonAuthority, + input: { + /** The department the request names, if any. */ + readonly requested: DepartmentId | undefined; + /** Every current department, which an organization-wide reach reads. */ + readonly departments: ReadonlyArray; + }, +): /** The readable scope, or the typed denial. */ +Result.Result => { + const reached = reachedDepartments(authority, "team-interest.read"); + const organizationWide = ReachedDepartments.$is("All")(reached); + const departmentScope = organizationWide ? input.departments : reached.departmentIds; + + const teamScope = reachedTeams(authority, "team-interest.read").flatMap((teamId) => { + const membership = authority.memberships.find((entry) => entry.teamId === teamId); + + return membership === undefined ? [] : [{ teamId, departmentId: membership.departmentId }]; + }); + + const denied = new TeamInterestScopeDenied({ + personId: authority.personId, + departmentId: input.requested, + }); + + if (!organizationWide && departmentScope.length === 0 && teamScope.length === 0) + return Result.fail(denied); + + const teams: TeamInterestReadScope["teams"] = teamScope.filter( + (team) => + (input.requested === undefined || team.departmentId === input.requested) && + !departmentScope.includes(team.departmentId), + ); + + const departmentIds = + input.requested === undefined + ? departmentScope + : departmentScope.includes(input.requested) + ? [input.requested] + : teams.length > 0 + ? [] + : undefined; + + return departmentIds === undefined + ? Result.fail(denied) + : Result.succeed( + // SAFETY: the one constructor of the evidence brand; the scope above is what it proves. + { personId: authority.personId, departmentIds, teams } as TeamInterestReadScope, + ); +}; diff --git a/packages/domain/src/organization/service.ts b/packages/domain/src/organization/service.ts index 4baa09ad..b0d3a174 100644 --- a/packages/domain/src/organization/service.ts +++ b/packages/domain/src/organization/service.ts @@ -26,6 +26,7 @@ import type { } from "./administration-schema.js"; import type { OrganizationAdministratorEvidence, + TeamInterestReadScope, OrganizationAuthorityInstant, OrganizationPersonAuthority, } from "./authority.js"; @@ -64,13 +65,6 @@ import type { Profile } from "../profile/service.js"; * Spec 0059 read filter: the authorized scope is explicit input (0055). A registration is in * scope when its department or its team is authorized. */ -export interface TeamInterestFilter { - readonly authorizedDepartmentIds: ReadonlyArray; - readonly authorizedTeamIds: ReadonlyArray; - readonly departmentId?: DepartmentId; - readonly semesterId?: SemesterId; -} - export type OrganizationListFailure = OrganizationDecodeError | OrganizationPersistenceError; export type OrganizationReadError = @@ -129,8 +123,10 @@ export interface OrganizationOperations { * registration_id ASC. No authorization happens here; no narrowing may * exceed the authorized set. */ + /** The registrations inside the reader's scope, optionally of one semester. */ readonly listTeamInterestRegistrations: ( - filter: TeamInterestFilter, + scope: TeamInterestReadScope, + semesterId?: SemesterId, ) => Effect.Effect< ReadonlyArray, OrganizationDecodeError | OrganizationPersistenceError From c2bb9751cf891fa07d4708fdf4144196fb6fb4a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:38:06 +0000 Subject: [PATCH 6/9] refactor(social-events): require creation evidence for a new event SocialEvents.create took the creator's person id and trusted its caller to have evaluated the create AccessSpec. requireSocialEventCreation now builds SocialEventCreation from the same candidate grants, and create takes it: the audited actor comes from the evidence, and a request for another department than the evidence names is a defect. A property test states the rule independently of the grants: an active global administrator creates anywhere, an active appointment creates in its department. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/backend/src/social-events/http.ts | 10 +++- .../src/social-events/postgres-layer.ts | 4 +- .../database/src/social-events/postgres.ts | 15 +++-- .../domain/src/authz/reach-evidence.test.ts | 34 +++++++++++ .../domain/src/social-events/authority.ts | 58 ++++++++++++++++++- packages/domain/src/social-events/service.ts | 5 +- 6 files changed, 114 insertions(+), 12 deletions(-) diff --git a/apps/backend/src/social-events/http.ts b/apps/backend/src/social-events/http.ts index faea169b..1c7c74cb 100644 --- a/apps/backend/src/social-events/http.ts +++ b/apps/backend/src/social-events/http.ts @@ -4,6 +4,7 @@ import { SocialEventId, SocialEvents, socialEventCandidateGrantScopes, + requireSocialEventCreation, socialEventDepartmentAccessContext, socialEventScopeAccessContext, type SocialEventFailure, @@ -327,6 +328,12 @@ const create = (request: Request, options: SocialEventsApiHttpOptions) => authorization, context: socialEventDepartmentAccessContext(authorization.authority, body.departmentId), }); + + // The AccessSpec admitted the candidate grants; the command takes them as evidence. + const creator = yield* Effect.fromResult( + requireSocialEventCreation(authorization.authority, body.departmentId), + ).pipe(Effect.mapError(() => Problem.make("authority.denied"))); + yield* runTransactionHook(options, request, "create", "after-create-authorization"); yield* SocialEvents.use(({ validateScope }) => validateScope({ departmentId: body.departmentId, semesterId: body.semesterId }), @@ -346,9 +353,8 @@ const create = (request: Request, options: SocialEventsApiHttpOptions) => operationId, }, execute: SocialEvents.use((events) => - events.create({ + events.create(creator, { commandId: SocialEventCommandId.make(identity.commandId), - actorPersonId: authorization.authority.personId, occurredAt: observedAt, eventId: SocialEventId.make(`social_event_${randomUUID()}`), request: body, diff --git a/packages/database/src/social-events/postgres-layer.ts b/packages/database/src/social-events/postgres-layer.ts index 36588329..d501da34 100644 --- a/packages/database/src/social-events/postgres-layer.ts +++ b/packages/database/src/social-events/postgres-layer.ts @@ -25,8 +25,8 @@ export const SocialEventsLive = Layer.effect( readSocialEventListPostgres(input).pipe(Effect.provideService(Database, database)), validateScope: (scope) => validateSocialEventScopePostgres(scope).pipe(Effect.provideService(Database, database)), - create: (command) => - createSocialEventPostgres(command).pipe(Effect.provideService(Database, database)), + create: (creator, command) => + createSocialEventPostgres(creator, command).pipe(Effect.provideService(Database, database)), }); }), ); diff --git a/packages/database/src/social-events/postgres.ts b/packages/database/src/social-events/postgres.ts index 90132fdd..dcb49725 100644 --- a/packages/database/src/social-events/postgres.ts +++ b/packages/database/src/social-events/postgres.ts @@ -9,6 +9,7 @@ import { SocialEventScope, SocialEventScopeResource, type CreateSocialEventCommand as CreateSocialEventCommandValue, + type SocialEventCreation, type SocialEventListResource as SocialEventListResourceValue, type SocialEventObservedAt as SocialEventObservedAtValue, type SocialEventResource as SocialEventResourceValue, @@ -278,15 +279,19 @@ export const readSocialEventListPostgres = ( ); /** - * Inserts the canonical event, one domain command receipt, and one audit row. - * The caller owns the serializable HTTP transaction and has already performed - * authority plus selected-scope validation before the generic receipt lookup. + * Inserts the canonical event, one domain command receipt, and one audit row, as the creator. + * The caller owns the serializable HTTP transaction and resolved the creator's evidence in it + * before the generic receipt lookup. The event's department is the evidence's department. */ export const createSocialEventPostgres = ( - input: CreateSocialEventCommandValue, + creator: SocialEventCreation, + input: Omit, ): Effect.Effect => Effect.gen(function* () { - const command = yield* decodeCreateCommand(input); + if (input.request.departmentId !== creator.departmentId) + return yield* Effect.die(new Error("social event creator evidence names another department")); + + const command = yield* decodeCreateCommand({ ...input, actorPersonId: creator.personId }); const scope = yield* validateSocialEventScopePostgres({ departmentId: command.request.departmentId, diff --git a/packages/domain/src/authz/reach-evidence.test.ts b/packages/domain/src/authz/reach-evidence.test.ts index 8bd3e57c..53eea3f2 100644 --- a/packages/domain/src/authz/reach-evidence.test.ts +++ b/packages/domain/src/authz/reach-evidence.test.ts @@ -16,6 +16,11 @@ import { DepartmentId, TeamId } from "../organization/schema.js"; import { canManagePlacements } from "../placements/policy.js"; import type { OrganizationOperations } from "../organization/service.js"; import type { PlacementExecution } from "../placements/service.js"; +import { + requireSocialEventCreation, + type SocialEventCreation, +} from "../social-events/authority.js"; +import type { SocialEventsOperations } from "../social-events/service.js"; import type { AdmissionsOperations } from "../admissions/service.js"; import { Delegation, @@ -262,6 +267,26 @@ it.prop( propertyOptions, ); +it.prop( + "requireSocialEventCreation admits an active global administrator anywhere and an active appointment in the department", + { authority, department }, + ({ authority, department }) => { + const result = requireSocialEventCreation(authority, department); + + expect(Result.isSuccess(result)).toBe( + authority.globalAdministrator === "Active" || + authority.memberships.some( + (membership) => membership.active && membership.departmentId === department, + ), + ); + + const facts = Result.isSuccess(result) ? result.success : result.failure; + expect(facts.personId).toBe(authority.personId); + expect(facts.departmentId).toBe(department); + }, + propertyOptions, +); + it("requireDepartmentReach evidence is the only way to satisfy a department-scoped command", () => { type OutcomeDecider = Parameters[0]["decider"]; @@ -280,6 +305,15 @@ it("requireDepartmentReach evidence is the only way to satisfy a department-scop expectTypeOf>().not.toExtend(); expectTypeOf>().not.toExtend(); + // A creator's person id is not creation evidence. + expectTypeOf< + Parameters[0] + >().toEqualTypeOf(); + expectTypeOf<{ + readonly personId: SocialEventCreation["personId"]; + readonly departmentId: SocialEventCreation["departmentId"]; + }>().not.toExtend(); + // A handler-built filter is not a team-interest scope. expectTypeOf<{ readonly personId: TeamInterestReadScope["personId"]; diff --git a/packages/domain/src/social-events/authority.ts b/packages/domain/src/social-events/authority.ts index 7f33dc48..9f64d810 100644 --- a/packages/domain/src/social-events/authority.ts +++ b/packages/domain/src/social-events/authority.ts @@ -1,4 +1,4 @@ -import { Result, Array } from "effect"; +import { Array, Data, Predicate, Result } from "effect"; import { PrincipalSchema, AuthorityRef, @@ -15,7 +15,7 @@ import { decodeGrant, } from "../authz/access.js"; import type { OrganizationPersonAuthority } from "../organization/authority.js"; -import type { DepartmentId } from "../organization/schema.js"; +import type { DepartmentId, PersonId } from "../organization/schema.js"; const authorityVersion = (authority: OrganizationPersonAuthority) => AuthorityVersion.make(`organization:${authority.evaluatedAt}`); @@ -126,3 +126,57 @@ export const socialEventCandidateGrantScopes = ( Array.filterMap(deriveSocialEventCandidateGrants(authority), (grant) => grant.capability.type === capability ? Result.succeed(grant.scope) : Result.failVoid, ); + +/** Type-only brand. Only {@link requireSocialEventCreation} builds this evidence. */ +declare const SocialEventCreationBrand: unique symbol; + +/** Proof that a person may create a social event in one department at the authority's instant. */ +export interface SocialEventCreation { + readonly [SocialEventCreationBrand]: "SocialEventCreation"; + readonly personId: PersonId; + readonly departmentId: DepartmentId; +} + +/** The person holds no create grant that covers the department. */ +export class SocialEventCreationDenied extends Data.TaggedError("SocialEventCreationDenied")<{ + readonly personId: PersonId; + readonly departmentId: DepartmentId; +}> {} + +/** + * Checks that a resolved authority may create a social event in one department, and returns the + * evidence that creation requires. + * + * @remarks + * It is the only constructor of {@link SocialEventCreation} and decides with the candidate grants + * that the create AccessSpec evaluates: an active global administrator creates everywhere, and an + * active appointment in the department creates there. + * + * @sideEffects none + * + * @example + * ```ts + * const creator = yield* Effect.fromResult(requireSocialEventCreation(authority, departmentId)); + * yield* events.create(creator, { commandId, occurredAt, eventId, request }); + * ``` + * + * @avoid Passing the creator's person id to `create`: the command then trusts that some caller + * checked the department. Require the evidence, which names the department it covers. + */ +export const requireSocialEventCreation = ( + /** The person's authority, resolved from current facts inside the command transaction. */ + authority: OrganizationPersonAuthority, + /** The department of the event. */ + departmentId: DepartmentId, +): /** Evidence for the department, or the typed denial. */ +Result.Result => + socialEventCandidateGrantScopes(authority, SOCIAL_EVENTS_CREATE_CAPABILITY).some( + (scope) => + Predicate.isTagged(scope, "Global") || + (Predicate.isTagged(scope, "Department") && scope.departmentId === departmentId), + ) + ? Result.succeed( + // SAFETY: the one constructor of the evidence brand; the grant check above is what it proves. + { personId: authority.personId, departmentId } as SocialEventCreation, + ) + : Result.fail(new SocialEventCreationDenied({ personId: authority.personId, departmentId })); diff --git a/packages/domain/src/social-events/service.ts b/packages/domain/src/social-events/service.ts index fdad88e6..95f4735c 100644 --- a/packages/domain/src/social-events/service.ts +++ b/packages/domain/src/social-events/service.ts @@ -2,6 +2,7 @@ import { Context, Effect } from "effect"; import type { OrganizationPersonAuthority } from "../organization/authority.js"; import type { DepartmentId, SemesterId } from "../organization/schema.js"; import type { SocialEventFailure } from "./errors.js"; +import type { SocialEventCreation } from "./authority.js"; import type { CreateSocialEventCommand, SocialEventListResource, @@ -36,8 +37,10 @@ export interface SocialEventsOperations { readonly validateScope: ( scope: SocialEventScope, ) => Effect.Effect; + /** Creates the event in the creator's department; a request for another department is a defect. */ readonly create: ( - command: CreateSocialEventCommand, + creator: SocialEventCreation, + command: Omit, ) => Effect.Effect; } From 416850cf0465aecbb9efd2cc5bb3da3df1c836c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:52:29 +0000 Subject: [PATCH 7/9] docs(authz): record the evidence rollout and what stays Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- STATE.md | 5 ++- docs/specs/authz-evidence.md | 77 ++++++++++++++++++++++++++++++------ 2 files changed, 67 insertions(+), 15 deletions(-) diff --git a/STATE.md b/STATE.md index e4798e65..d326778a 100644 --- a/STATE.md +++ b/STATE.md @@ -110,8 +110,9 @@ Fix an instance when a change touches it (see [AGENTS.md](AGENTS.md#construction - `apps/homepage/src/lib/public-application.ts` lists its problem codes by hand and omits `header.malformed`. The homepage problem mappers (`mapPublicApplicationError`, `publicTeamApplicationPageFailure`, `failedPublicTeamApplication`) still accept a plain problem-shaped object besides the SDK's `Problem`; the dashboard reads problems only through `nativeProblemFrom`. - Instants: domain fields still use `Rfc3339InstantSchema`, not `Instant` (`packages/domain/src/time.ts`). -- Authorization is trusted by convention: 13 authorize helpers return `void` or a value any module can construct, and a command runs whether or not its caller called one. - The Organization create commands take evidence that only `requireOrganizationAdministrator` constructs; the other contexts await the rollout in [authorization evidence](docs/specs/authz-evidence.md). +- Authorization evidence ([specification](docs/specs/authz-evidence.md)): nine branded evidence types, each with one constructor, gate the commands of Organization, onboarding, admission outcomes, placements, schools, recruitment maintenance, certificates, team applications, and social events. + Receipts (`packages/database/src/receipt/postgres.ts`, `authorizeReceiptMutation`) still export a constructible `ReceiptMutationAuthorization`, and content (`apps/backend/src/content/http-access.ts`) keeps a handler gate in front of the adapter's own check. + `OrganizationPersonAuthority` is not branded at its reader, so a fabricated authority can still mint evidence. The authority instant (M2), per-slice cutovers (M3), and deletion of the string helpers such as `compareRfc3339Instants` (M4) remain. - Receipt keyset cursors carry microsecond text (`packages/database/src/receipt/cursor.ts`). They stay exact because storage is millisecond; M3 moves them to `Instant`. - `packages/database/runtime/schema-boundary-postgres-proof-main.ts` (`proof:schema-boundary-postgres`, run by no `just` set) failed at `65e98276`, before and after its move to an Effect program, with "New interview conduct requires an explicit recommendation": its seed inserts an interview conduct without the recommendation that migration 0037 requires of every new conduct. diff --git a/docs/specs/authz-evidence.md b/docs/specs/authz-evidence.md index cee4f4f1..02584242 100644 --- a/docs/specs/authz-evidence.md +++ b/docs/specs/authz-evidence.md @@ -1,8 +1,10 @@ # Authorization evidence -Status: pilot (2026-09-28), on branch `refactor/authz-evidence-organization`. The operator asked for one -context first. The pilot covers the three Organization administration create commands. The -operator has not approved the rollout to the other contexts. +Status: rollout (2026-09-28), on branch `refactor/authz-evidence-organization`. The operator +approved the full refactor after the pilot. Nine evidence types cover Organization administration, +team interest, onboarding, admission outcomes, placement boards, schools, recruitment maintenance, +days served, certificates, team applications, and social-event creation. Receipts and content stay +as they are, for the reasons under [Deliberately left](#deliberately-left). Remove this specification when every domain command that needs authority takes evidence that only its interpreter constructs, and `AGENTS.md#construction-over-trust` names the construction. @@ -106,13 +108,62 @@ resolved to the repository's copy: a Vitest config that includes `src/organizati and a Stryker config with `testRunner: "vitest"`, `inPlace: true`, `concurrency: 1`, `coverageAnalysis: "off"`, and `mutate: ["src/organization/authority.ts:"]`. -## Rollout, after operator approval - -One context per branch, in order of call sites: the person and anonymous helpers of -`apps/backend/src/http-api/problem.ts` (44), content, admission, recruitment, team applications, -schools, placements certificates, receipts (seal the existing constructors). Each branch deletes the -context's void helper and its runtime re-check in the adapter. - -Stryker is not in the root catalog. The pilot runs it outside the catalog and reports the score. It -joins the catalog, `just`, and hosted Checks only with the rollout, so that no check exists that -nothing runs. +## Rollout results + +Each evidence type is an interface with a type-only brand (`declare const …Brand: unique symbol`) +and exactly one constructor, marked with a `// SAFETY:` comment. The command takes the evidence, +not a person id. + +| Evidence | Minted by | Consumed by | +| ------------------------------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------- | +| `OrganizationAdministratorEvidence` | `requireOrganizationAdministrator` (domain) | `createDepartment`, `createTeam`, `createFieldOfStudy` | +| `TeamInterestReadScope` | `requireTeamInterestScope` (domain) | `listTeamInterestRegistrations` | +| `DepartmentReach` | `requireDepartmentReach` (domain) | onboarding commands, `recordAdmissionOutcome`, placement board and coverage mutations | +| `SocialEventCreation` | `requireSocialEventCreation` (domain) | `SocialEvents.create` | +| `SchoolCommandAuthorization` | `authorizeSchoolCommand` (database, in the transaction) | `executeCommand` | +| `RecruitmentMaintenanceAuthorization` | `authorizeMaintenance` (database) | `maintainRecruitment` | +| `DaysServedConfirmationAuthorization` | `authorizeDaysServedConfirmation` (database) | `confirmDaysServed` | +| `CertificateIssueAuthorization` | `authorizeCertificateIssue` (database) | `issueCertificate` | +| `TeamApplicationAuthorization` | `authorizeTeamApplicationAction` (database) | `deleteApplication`, `reviseIntake` | + +Deleted: `authorizeOrganizationActor`, `decodeOrganizationActor`, `organizationActorFrom`, +`resolveActor`, `CertificateCommandTarget`, `authorizeCertificateCommand`, `TeamInterestFilter`, +and the second authorization inside `executeSchoolCommand`. Commands whose evidence carries a +department (`DepartmentReach`, `SocialEventCreation`) fail when the locked row belongs to another +department: admission outcomes answer `authority.denied`, and placements and social events die, +because the handler minted the evidence for the row's own department. + +Measured on `c2bb975`, with PostgreSQL 18: + +- Type checks pass for domain, database, backend, dashboard, `tools/e2e`, and `tools/verification`. +- Focused Vitest suites pass: domain (organization, authz, placements, admissions: 165 tests), + database (onboarding, admissions, placements, recruitment, schools, team applications including + PgBouncer, organization, `database.test.ts`), backend (organization, onboarding, admission, + placements, recruitment, schools, directory, team applications), and the onboarding delivery + test of `tools/verification`. +- Property tests in `packages/domain/src/authz/reach-evidence.test.ts`: `requireDepartmentReach` + agrees with `reaches`, with department administration for global-administrator capabilities, + with `canManagePlacements`, and with `admissionOutcomePermission === "Decide"`; the team interest + scope agrees with first-membership semantics; social-event creation agrees with its grant check. + Generated inputs reach both outcomes (127 grants and 173 denials in one measured run). +- The authorization-rules proof output is unchanged apart from process ids and timestamps. +- Browser journeys: see the table in the commit that records them. + +## Deliberately left + +- Receipts. `ReceiptMutationAuthorization` is minted in the committing transaction, and the pure + `decideCommand` re-checks access on the locked receipt. Sealing the constructor removes neither + check, so it adds a brand without deleting code. +- Content. The database adapter resolves authority itself, in the transaction. The handler-side + `authorizeContentOperation` is a second gate in front of it, not the authority. +- `authorizeAnonymous` and `authorizePerson` in `apps/backend/src/http-api/problem.ts`. They check + the credential, not authority. `authorizeAnonymous` has no production consumer; a test keeps it + consistent with the AccessSpec. The `authorizePerson` sites that run after a domain call only + shape the response. +- `OrganizationPersonAuthority` is not branded at its reader. A caller that fabricates an authority + can still mint evidence. Branding it touches every reader of the fact port and is its own slice. + +Stryker is not in the root catalog. Stryker 10 with Vitest 5 runs no test nested in `describe` for a +mutant (upstream stryker-js issue #6210; fix proposed in PR #6214). A locally patched runner kills +18 of 18 mutants in `describe` as well. Stryker joins the catalog, `just`, and hosted Checks once a +release contains the fix, so that no check exists that nothing runs. From 3a55e3aa75f15273b1ab0ba9fe9cd3792e55533c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 03:32:43 +0000 Subject: [PATCH 8/9] fix(e2e): end a timed-out readiness probe under Bun Bun's node:http destroy() emits close but no error, so a probe attempt that timed out never resumed, and waitForHttp outlived its deadline. The golden team-application journey hung for 25 minutes before its first page when the homepage's first request was slow. A probe against a silent listener with a one-second deadline hung past 8 s before this change and fails at 4.1 s after. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- tools/e2e/golden-harness.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/e2e/golden-harness.ts b/tools/e2e/golden-harness.ts index cdd60b1b..ab3279e2 100644 --- a/tools/e2e/golden-harness.ts +++ b/tools/e2e/golden-harness.ts @@ -495,6 +495,9 @@ export const waitForHttp = (probe: HttpProbe) => request.once("timeout", () => request.destroy()); request.once("error", () => resume(Effect.succeed(0))); + // Bun's `destroy()` emits `close` but no `error`, so an attempt that timed out resumes + // here; without it one slow attempt outlives the deadline. A later resume is ignored. + request.once("close", () => resume(Effect.succeed(0))); request.end(); return Effect.sync(() => request.destroy()); From 69796377cfece929ff1a41acdfe32bb4accd9877 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 03:34:32 +0000 Subject: [PATCH 9/9] docs(authz): record the journey results of the evidence rollout Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- docs/specs/authz-evidence.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/specs/authz-evidence.md b/docs/specs/authz-evidence.md index 02584242..36a81adc 100644 --- a/docs/specs/authz-evidence.md +++ b/docs/specs/authz-evidence.md @@ -147,7 +147,24 @@ Measured on `c2bb975`, with PostgreSQL 18: scope agrees with first-membership semantics; social-event creation agrees with its grant check. Generated inputs reach both outcomes (127 grants and 173 denials in one measured run). - The authorization-rules proof output is unchanged apart from process ids and timestamps. -- Browser journeys: see the table in the commit that records them. +- Browser and golden journeys, each exit 0 on a clean tree with PostgreSQL 18 and Chromium: + + | Journey | Exercises | Revision | Seconds | + | ------------------------------ | --------------------------------------------------------- | --------- | ------- | + | `just e2e organization` | administrator evidence; member denied 403; replay | `c2bb975` | 67 | + | `just e2e schools` | school directory authority matrix | `416850c` | 83 | + | `just e2e social-events` | social-event creation | `416850c` | 77 | + | `just e2e substitutes` | admission outcomes | `416850c` | 76 | + | `just e2e onboarding` | coordinator invitation; wrong department denied | `416850c` | 77 | + | `just e2e recruitment` | recruitment session | `416850c` | 55 | + | `just golden school-service` | placement board, coverage, forbidden step (26 steps) | `416850c` | 118 | + | `just golden team-application` | intake revision, deletion, delivery recovery | `3a55e3a` | 88 | + + The first team-application run at `416850c` hung for 25 minutes before its first page. The + base `f433ea9` passed, and a rerun of `416850c` passed in 91 s. The cause was the readiness probe + of `tools/e2e/golden-harness.ts`, fixed in `3a55e3a`. No journey drives team interest in a + browser (`tools/conventions/src/journeys.ts` excludes its spec); the database and backend tests + cover it. ## Deliberately left