diff --git a/STATE.md b/STATE.md index 4b334309..d326778a 100644 --- a/STATE.md +++ b/STATE.md @@ -110,6 +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 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/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/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/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/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..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, @@ -20,14 +19,15 @@ import { Organization, OrganizationCommandId, OrganizationLifecycleCommand, + mapOrganizationAuthorityToOrganizationActor, + requireTeamInterestScope, + requireOrganizationAdministrator, SemesterId, type TeamId, TeamJsonSchema, - type OrganizationActor, 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"; @@ -61,10 +61,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 +101,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 +405,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 +456,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 +477,7 @@ const createDepartment = (request: Request, input: OrganizationApiHttpOptions) = commandId: OrganizationCommandId.make(identity.commandId), ...payload, }), - actor, + administrator, ), ).pipe( Effect.flatMap(({ observation }) => @@ -532,7 +527,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 +548,7 @@ const createTeam = (request: Request, input: OrganizationApiHttpOptions) => commandId: OrganizationCommandId.make(identity.commandId), ...payload, }), - actor, + administrator, ), ).pipe( Effect.flatMap(({ observation }) => @@ -603,7 +598,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 +619,7 @@ const createFieldOfStudy = (request: Request, input: OrganizationApiHttpOptions) commandId: OrganizationCommandId.make(identity.commandId), ...payload, }), - actor, + administrator, ), ).pipe( Effect.flatMap(({ observation }) => @@ -800,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 e4e54c2e..09a8b826 100644 --- a/apps/backend/src/organization/team-interest.http.test.ts +++ b/apps/backend/src/organization/team-interest.http.test.ts @@ -11,11 +11,11 @@ import { TeamId, SemesterId, MembershipId, - OrganizationActorSchema, DepartmentJsonSchema, Organization, PersonId, type OrganizationOperations, + type TeamInterestReadScope, } from "@vektorprogrammet/domain/organization"; import { DateTime, Effect, Layer, Schema } from "effect"; import { describe, expect, it } from "@effect/vitest"; @@ -100,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]; @@ -113,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); @@ -280,10 +273,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"); @@ -364,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"]); }), ); @@ -376,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", @@ -402,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/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/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/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/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/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/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/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/docs/constructs.md b/docs/constructs.md index b12e2565..cf75a186 100644 --- a/docs/constructs.md +++ b/docs/constructs.md @@ -17,6 +17,10 @@ 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. + - [`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 new file mode 100644 index 00000000..13f711d2 --- /dev/null +++ b/docs/constructs/authority-evidence.md @@ -0,0 +1,127 @@ +# 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. + +## `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. + +```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:271](../../packages/domain/src/organization/authority.ts#L271) + +**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. + +## `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/docs/specs/authz-evidence.md b/docs/specs/authz-evidence.md new file mode 100644 index 00000000..36a81adc --- /dev/null +++ b/docs/specs/authz-evidence.md @@ -0,0 +1,186 @@ +# Authorization evidence + +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. + +## 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 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 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 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 + +- 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. 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/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/database.test.ts b/packages/database/src/database.test.ts index ca5fe2b7..b1e4e88c 100644 --- a/packages/database/src/database.test.ts +++ b/packages/database/src/database.test.ts @@ -40,7 +40,11 @@ import { departmentIdForCommand, Organization, OrganizationCommandId, + OrganizationPersonAuthoritySchema, + requireOrganizationAdministrator, + requireTeamInterestScope, } from "@vektorprogrammet/domain/organization"; +import { activeAdministratorAuthority } from "@vektorprogrammet/domain/organization/authority-fixtures"; import { RecruitmentAssignmentObservationSchema, RecruitmentScheduleObservationSchema, @@ -91,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, @@ -4082,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 }; }); @@ -4119,40 +4133,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 +4416,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 +4475,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/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/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/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/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.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..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, @@ -89,7 +90,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( @@ -158,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) => @@ -174,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/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/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/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..53eea3f2 --- /dev/null +++ b/packages/domain/src/authz/reach-evidence.test.ts @@ -0,0 +1,334 @@ +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, + requireTeamInterestScope, + type TeamInterestReadScope, + 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 { 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, + DelegationArea, + DelegationId, + ORGANIZATION_CAPABILITIES, + ORGANIZATION_CAPABILITY_IDS, + OrganizationCapability, +} from "./delegation.js"; +import { + type DepartmentReach, + DepartmentReachDenied, + ReachedDepartments, + reachedDepartments, + reachedTeams, + 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, +); + +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.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"]; + + 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 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"]; + 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"]; + 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/AGENTS.md b/packages/domain/src/organization/AGENTS.md index 176064da..12a2b9bb 100644 --- a/packages/domain/src/organization/AGENTS.md +++ b/packages/domain/src/organization/AGENTS.md @@ -80,6 +80,13 @@ 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. +- [`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. [//]: # "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..1d8c024b --- /dev/null +++ b/packages/domain/src/organization/authority-evidence.test.ts @@ -0,0 +1,130 @@ +import { 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; + +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), + ); + + 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( + "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")); + + if (Result.isSuccess(result)) { + expect(result.success.actor).toEqual(actor); + } else { + expect(result.failure.actorPersonId).toBe(authority.personId); + } + }, + propertyOptions, +); + +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(); + + // 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-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/organization/authority.ts b/packages/domain/src/organization/authority.ts index 55b77d7a..60451fcc 100644 --- a/packages/domain/src/organization/authority.ts +++ b/packages/domain/src/organization/authority.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import { Data, Result, Schema } from "effect"; import { type AdmissionPeriodActor, AdmissionPeriodActorSchema, @@ -10,13 +10,23 @@ 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, 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 +232,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 => { @@ -252,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 b51ef0e2..b0d3a174 100644 --- a/packages/domain/src/organization/service.ts +++ b/packages/domain/src/organization/service.ts @@ -23,9 +23,13 @@ import type { CreateFieldOfStudyResult, CreateTeamCommand, CreateTeamResult, - OrganizationActor, } from "./administration-schema.js"; -import type { OrganizationAuthorityInstant, OrganizationPersonAuthority } from "./authority.js"; +import type { + OrganizationAdministratorEvidence, + TeamInterestReadScope, + OrganizationAuthorityInstant, + OrganizationPersonAuthority, +} from "./authority.js"; import type { OrganizationDirectoryFacts } from "./directory.js"; import type { SemesterId, TeamInterestRegistration } from "./schema.js"; import type { @@ -61,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 = @@ -126,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 @@ -152,15 +151,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/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 d0d13eca..a28eae88 100644 --- a/packages/domain/src/placements/service.ts +++ b/packages/domain/src/placements/service.ts @@ -1,13 +1,15 @@ 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 { @@ -61,12 +63,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 { @@ -153,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. */ @@ -183,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/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; } 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; /** 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.", 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()); 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),