diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 26ea4e7..c59ebd7 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -17,6 +17,22 @@ concurrency: cancel-in-progress: true jobs: + step: + name: Tutorial helper (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v7.0.1 + - uses: actions/setup-node@v7.0.0 + with: + node-version: 24.21.0 + - name: Check tutorial step helper + run: node --test shared/tools/step/run.test.mjs + changes: name: Select projects runs-on: ubuntu-latest diff --git a/p1-task-manager/README.md b/p1-task-manager/README.md index 09930bb..d73512a 100644 --- a/p1-task-manager/README.md +++ b/p1-task-manager/README.md @@ -10,6 +10,9 @@ The application handles authentication, sessions, request integrity, validation, tenant-scoped lists, and optimistic concurrency. Follow the [tutorial](docs/tutorials.md) to add authorization to the [starting application](https://github.com/GluuFederation/cedarling-tutorials/tree/21b0832be4b31271320df992d04e9d97667d0e38/p1-task-manager). +The [tutorial helper](../shared/tools/step/README.md) copies the files +needed at each integration step. + ## Architecture ```mermaid @@ -35,7 +38,7 @@ The commands work from PowerShell, macOS terminals, and Ubuntu shells. ## Run -Start the application and its own IdP: +From `p1-task-manager/`, start the application and its own IdP: ```bash docker compose up --build @@ -65,15 +68,24 @@ The policy store trusts only issuer `http://localhost:18001` and audience ## Exercise -Compare the task board using these accounts: +Choose an account below. The sign-in page prefills its username; enter it if +needed and use any non-empty password, such as `cedarling-is-awesome`. +These credentials are for the local tutorial IdP only. - Alex can view and edit his assigned Tenant A task, but cannot create one. - Mina can create tasks in Tenant A and manage tasks she owns. - Sam can view and edit his own Tenant B task, but cannot access Tenant A tasks. -Try the [direct API request](docs/tutorials.md#create-a-task-as-alex) -as well as the visible controls. A hidden button alone does not prove that -the server enforces permission. +Try the [direct API request](docs/tutorials.md#retry-alexs-request-then-edit-his-assigned-task) +as Alex: creating a task returns `403 forbidden`. He can still edit his assigned +task. Compare these responses with the visible controls. + +To restore native fixtures, stop the app and IdP, run `pnpm reset`, then +`pnpm dev` and sign in again. Reset deletes this project's `.data` directory, +including tasks, sessions, and stored policy artifacts; configuration is preserved. +Docker uses separate volumes. To reset that stack, run +`docker compose down --volumes`, then `docker compose up --build`. This removes +the stack's application data and generated configuration, so sign in again. ## Commands diff --git a/p1-task-manager/docs/tutorials.md b/p1-task-manager/docs/tutorials.md index d5c921d..0a4364c 100644 --- a/p1-task-manager/docs/tutorials.md +++ b/p1-task-manager/docs/tutorials.md @@ -1,11 +1,12 @@ --- slug: protect-a-nodejs-rest-api-with-cedarling title: Protect a Node.js REST API with Cedarling -summary: Secure a task manager with token-based server decisions and conservative browser controls. +summary: Secure a task manager with Token-Based Access Control (TBAC) on the server and permission-aware browser controls. order: 20 socialImage: ./assets/social-card.webp socialImageAlt: Browser guidance and Fastify server enforcement with embedded Cedarling for task actions. -lastVerified: 2026-10-07T19:56:00Z +lastVerified: 2026-10-09T13:42:36Z +lastUpdated: 2026-10-09T17:29:36Z --- # Protect a Node.js REST API with Cedarling @@ -22,13 +23,15 @@ keeping his assigned-task edits working. We'll also keep each tenant's tasks separate and check who owns or is assigned to a task before allowing reads and edits. Creation, assignment, completion, and deletion have additional role, ownership, or assurance requirements. A new -assignee must belong to the same tenant. The API will check these rules using -validated access tokens and current database facts. Browser decisions will guide -the controls; direct API requests will face the same server checks. +assignee must belong to the same tenant. We'll use +[Token-Based Access Control (TBAC)](https://docs.jans.io/stable/cedarling/#proof-based-authorization-token-based-access-control-tbac): +Cedarling validates the access token and evaluates it alongside current database +facts supplied by the API. Browser decisions will guide the controls; direct API +requests will face the same server checks. -## Build the integration or try the finished app +## Start from the baseline or run the finished task manager -- To build the integration, start with [Start the baseline app](#start-the-baseline-app). We'll add policies and server enforcement, then browser controls. +- To build the integration, use native Node.js and start with [Start the baseline app](#start-the-baseline-app). We'll add policies and server enforcement, then browser controls and their tests. - To try the finished app, clone and run the [finished project on main](https://github.com/GluuFederation/cedarling-tutorials/tree/main/p1-task-manager) using its README, then go to [Check allowed and denied operations](#check-allowed-and-denied-operations). This version should already deny the operation we'll reproduce in the baseline. If you're building from the starting project, open each **Required step** section @@ -36,7 +39,7 @@ and complete its instructions before continuing. These sections contain the file and changes we'll need.
-What you'll need +Before you start - For the coding steps: Git, Node.js 24.21+ within 24.x, and pnpm 10.17.1. The project supplies its own tutorial identity provider. - Docker with Compose is optional for running the baseline or finished example. Use native Node.js while working through the coding steps. @@ -45,12 +48,8 @@ and changes we'll need.
-At each copying step, open the linked file on GitHub, choose **Raw**, and copy -its full contents into the stated destination in your baseline checkout. The -short examples explain the parts we'll focus on. Create missing parent -directories first. Code paths and commands are -relative to `p1-task-manager/`; repository-level `shared/` files go one directory -above it. +Run the commands from `p1-task-manager/`. Paths under `shared/` are relative to +the repository root. ## Meet the task manager and its users @@ -70,10 +69,10 @@ assurance standard has been met. We'll use three sample accounts: - **Mina** has the Tenant A owner role and assurance level 2. She manages her team's work. - **Sam** is a Tenant B external user with assurance level 1. His work must stay separate from Tenant A. -![Mina and Alex belong to Tenant A; Sam belongs to Tenant B. Their roles are owner, contributor, and user.](./assets/meet-the-users.webp) +![Mina is Tenant A's owner and Alex its contributor. Sam is shown as a Tenant B user; his policy role is external.](./assets/meet-the-users.webp) -_Each decision depends on the user's facts and the action and resource involved. -A role or tenant label alone doesn't settle it._ +_Mina and Alex work in Tenant A. Sam works in Tenant B with the `external` role; +the illustration labels him as a user._ Cedarling is the policy decision point (PDP). The API handlers are the policy enforcement points (PEPs): they act on Cedarling's decisions. The browser also @@ -87,22 +86,21 @@ a creation request directly to the API, then keep it for comparison after integr ### Start the baseline app -Use a separate checkout so the exercise does not change an existing database: +Use a separate checkout for P1. Keep it if you later follow another project; +start that tutorial in its own checkout so shared files and exercise data stay separate: ```bash git clone https://github.com/GluuFederation/cedarling-tutorials.git cedarling-p1 cd cedarling-p1 git switch --detach 21b0832be4b31271320df992d04e9d97667d0e38 cd p1-task-manager -``` - -With Docker, start the application and its own identity provider: -```bash -docker compose up --build +# Prepare the tutorial steps. +git restore --source=850e38fd5dc4758848e3fcb05af40f3a9c68760c --worktree -- ../shared/tools/step +node ../shared/tools/step/run.mjs p1 init --source 850e38fd5dc4758848e3fcb05af40f3a9c68760c ``` -For Node.js startup at this commit, use two terminals. In the first: +For the coding path, start Node.js in two terminals.[^7] In the first: ```bash pnpm --dir ../shared/identity-provider install --frozen-lockfile @@ -118,6 +116,22 @@ In a second terminal, from the same project directory: pnpm dev ``` +
+Optional: Run the starting app with Docker + +Instead of the native commands above, run: + +```bash +docker compose up --build +``` + +This starts the app and its IdP together. For Docker-only exploration, you can +skip the two helper commands in the cloning snippet. Run them before starting +the coding steps. Native and Docker execution use the same ports and keep +separate databases and sessions; run only one stack at a time. + +
+ Open `http://localhost:17001`; the IdP runs at `http://localhost:18001`. Use one startup method at a time. Sign in as **Alex**. The development IdP usually prefills `alex`; enter it if the field is empty. Use any non-empty @@ -150,10 +164,18 @@ whether he may create a task. Keep a capture of the response and saved task. Stop the application before changing code. For native execution, stop its second terminal but keep the IdP -running. For Docker, use `Ctrl+C`, then `docker compose down`; this keeps -its data volume. The coding steps below use native startup; -if you started with Docker, install dependencies and start the native IdP using -the first-terminal commands above before continuing. +running. + +
+If using Docker: Switch to native development + +Stop the stack with `Ctrl+C`, then run `docker compose down` to keep its data +volume. Run the helper setup commands if you skipped them, then install +dependencies and start the native IdP using the first-terminal commands above. +Sign in again when the native app starts. Tasks created in Docker remain in +that stack's database. + +
## Where should we check permission? @@ -199,6 +221,10 @@ An owner role is a tenant role; a task owner is the user in the task's ### Create the policy-store files +Keep the [Cedar policy syntax](https://docs.cedarpolicy.com/policies/syntax-policy.html) +and [Cedar schema syntax](https://docs.cedarpolicy.com/schema/human-readable-schema.html) +references handy while working on these files. + Let's create a `policy-store/` directory at the project root using Cedarling's [directory-based format](https://docs.jans.io/stable/cedarling/reference/cedarling-policy-store/#2-new-directory-based-format): @@ -216,7 +242,11 @@ policy-store/
Required step: Create the five policy-store files -Create each file at the path shown above and copy its complete linked contents: +Run the first step, then read how these five files fit together: + +```bash +node ../shared/tools/step/run.mjs p1 policy-store +``` - [`metadata.json`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/policy-store/metadata.json) identifies the policy store and version. - [`schema.cedarschema`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/policy-store/schema.cedarschema) defines actions, entities, and request context. @@ -232,8 +262,8 @@ We'll look at the schema, issuer mapping, and `Create` policy from those files n version `1.0.0`. The version identifies the reviewed rules; the generated archive's SHA-256 hash identifies the exact file. `schema.cedarschema` defines the request types the policies accept. The two policy files hold server and browser -rules in the same store. P1 supplies current facts with each request, so it needs -no default entities, templates, or custom issuers.[^4] +rules in the same store. P1 supplies the current user and task facts with each +authorization request.[^4] | Design question | P1 answer | | ----------------------------------- | -------------------------------------------------------------------------------------- | @@ -245,9 +275,15 @@ no default entities, templates, or custom issuers.[^4] | Which request facts change? | Boundary, current user, proposed assignee's current tenant | | What else must the schema describe? | Access-token attributes/tags, trusted-issuer URL, generated token context, six actions | +For a permission check, Cedar asks who is acting (`principal`), what they want +to do (`action`), and what they want to act on (`resource`). `context` holds +additional facts. Here, `Create` targets Tenant A, and the server supplies the +current user's role and assurance in `context.user`. Cedarling validates the +signed token and makes its claims available through `context.tokens`. + Server multi-issuer requests supply tokens rather than a principal entity. -`Task::Any` is an empty type for Cedar's action principal declaration, not another -user record or role. Browser requests use `Task::User`. +`Task::Any` is an empty type used in Cedar's action principal declaration. +Browser requests use `Task::User`. In the `Task` namespace, `RequestContext` declares the `boundary`, current user, optional proposed-assignee tenant, and Cedarling token context. Each of the six @@ -256,11 +292,14 @@ a `Tenant`: ```cedar // policy-store/schema.cedarschema -action "Create" appliesTo { - principal: [Any, User], - resource: [Tenant], - context: RequestContext -}; +namespace Task { + // ... entity types, RequestContext, and other actions omitted. + action "Create" appliesTo { + principal: [Any, User], + resource: [Tenant], + context: RequestContext + }; +} ``` The `P1TaskManager` namespace describes the trusted issuer and the access-token @@ -333,11 +372,10 @@ assurance conditions fail. Mina belongs to the same tenant with role `owner` and assurance level `2`, so she meets those conditions. Her request must also pass the token subject, audience, and scope checks above. -`p1taskmanager_access_token` is Cedarling's generated token-context key, not -another namespace. Policies read dynamic `sub` and `aud` claims as tags. Scope -is a space-separated list of permissions. This rule accepts `task.create` as -one complete entry, wherever it appears in that list. It doesn't -mistake `task.create.extra` for the same permission. +`p1taskmanager_access_token` is Cedarling's generated key in `context.tokens`. +Policies read the `sub` and `aud` claims as tags. The `scope` claim contains +space-separated permissions; this rule matches `task.create` as a whole entry +anywhere in that list. `policies/server-access.cedar` contains one `permit` per protected API action. All six check the signed token's subject and API audience against trusted server @@ -383,8 +421,10 @@ the application. ## Add Cedarling to the app -The policies now describe the permissions. Let's connect them to the API first -and prove that Alex cannot create a task. Then we'll update the browser controls. +We'll connect the policies to the API first and check Alex's creation request. +The diagram follows a task request through Fastify's permission check, before +any protected read or write. It also shows how the browser uses safe facts and +allowed actions from the server to guide its controls. ```mermaid flowchart TD @@ -401,11 +441,12 @@ flowchart TD ### Install Cedarling and build the policy archive -From `p1-task-manager/`: +From `p1-task-manager/`, install Cedarling and match the completed app's dependencies: ```bash pnpm add --save-exact @janssenproject/cedarling_wasm@0.0.468 fflate@0.8.3 -pnpm add --save-dev --save-exact @cedar-policy/cedar-wasm@4.12.0 +pnpm add --save-exact @fastify/static@10.1.5 @fastify/swagger@9.9.0 +pnpm add --save-dev --save-exact @cedar-policy/cedar-wasm@4.12.0 @types/node@24.19.0 ``` The Cedar package checks source syntax; the Cedarling SDK makes application @@ -417,8 +458,11 @@ We'll use a shared builder to validate the policy files and package them for Ced
Required step: Create the shared archive builder -Create these files in the repository-level `shared/` directory and copy their -complete linked contents: +Add the builder and its type declarations: + +```bash +node ../shared/tools/step/run.mjs p1 archive-builder +``` - [`shared/policy-store.mjs`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/shared/policy-store.mjs) validates and packages the policy store. - [`shared/policy-store.d.mts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/shared/policy-store.d.mts) supplies the builder's TypeScript declarations. @@ -439,12 +483,18 @@ Keep `policy-store/` in Git. Rebuild and restart after policy edits. ### Create one Cedarling instance for the server With the archive built, we'll load it once and connect the decisions to all six -server operations. Copy the complete files together so their imports and types agree. +server operations. This step updates the server and its shared types together.
Required step: Update server authorization and its shared types -Replace these existing files with their complete linked contents: +Run: + +```bash +node ../shared/tools/step/run.mjs p1 server +``` + +The step updates these files: - [`src/server/authorization-trace.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/src/server/authorization-trace.ts) loads Cedarling, builds requests, and records decisions. - [`src/server/app.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/src/server/app.ts) enforces decisions in routes and serves browser permission data. @@ -452,14 +502,16 @@ Replace these existing files with their complete linked contents: - [`src/server/config.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/src/server/config.ts) supplies server configuration. - [`tsconfig.server.json`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/tsconfig.server.json) includes the shared types in the server build. -Create [`src/shared/authorization.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/src/shared/authorization.ts) -and copy its full contents for the shared actions, resources, and permission -envelope types. Remove `src/server/capabilities.ts`, which it replaces. +It also adds [`src/shared/authorization.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/src/shared/authorization.ts) +for shared actions, resources, and permission envelope types, and removes +`src/server/capabilities.ts`, which those types replace.
-`createServerAuthorization()` loads the archive relative to `options.projectRoot`. -It registers the archive and initializes Cedarling: +In `src/server/authorization-trace.ts`, `createServerAuthorization()` receives +the project and data directories from startup. It loads the archive relative to +`options.projectRoot`. The excerpt keeps initialization visible and omits +archive registration and the returned methods: ```ts // src/server/authorization-trace.ts @@ -468,80 +520,124 @@ import path from "node:path"; import { initFromArchiveBytes } from "@janssenproject/cedarling_wasm"; const archiveName = "policy-store.cjar"; -const archive = new Uint8Array( - await readFile(path.join(options.projectRoot, ".local", archiveName)), -); -const cedarling = await initFromArchiveBytes( - { - CEDARLING_APPLICATION_NAME: "P1 Task Manager server", - CEDARLING_LOG_TYPE: "memory", - CEDARLING_LOG_TTL: 300, - CEDARLING_JWT_SIG_VALIDATION: "enabled", - CEDARLING_JWT_SIGNATURE_ALGORITHMS_SUPPORTED: ["RS256"], - CEDARLING_STRICT_SCHEMA_VALIDATION: "enabled", - CEDARLING_TRUSTED_ISSUER_LOADER_TYPE: "SYNC", - }, - archive, -); +// ... other imports, types, and helpers omitted. + +export async function createServerAuthorization( + options: Readonly<{ + projectRoot: string; + dataDirectory: string; + }>, +): Promise { + const archive = new Uint8Array( + await readFile(path.join(options.projectRoot, ".local", archiveName)), + ); + // ... calculate the digest, read metadata, and register the archive. + const cedarling = await initFromArchiveBytes( + { + CEDARLING_APPLICATION_NAME: "P1 Task Manager server", + CEDARLING_LOG_TYPE: "memory", + CEDARLING_LOG_TTL: 300, + CEDARLING_JWT_SIG_VALIDATION: "enabled", + CEDARLING_JWT_SIGNATURE_ALGORITHMS_SUPPORTED: ["RS256"], + CEDARLING_STRICT_SCHEMA_VALIDATION: "enabled", + CEDARLING_TRUSTED_ISSUER_LOADER_TYPE: "SYNC", + }, + archive, + ); + if (cedarling.loadedTrustedIssuersCount() < 1) { + await cedarling.shutDown(); + throw new Error("P1 requires at least one trusted issuer"); + } + // ... build the policy descriptor and return authorization methods. +} ``` -Start the IdP first. The initialization code requires `loadedTrustedIssuersCount()` -to be at least one before serving requests. `src/server/main.ts` owns the instance, -passes its authorization functions to `buildApp()`, and arranges for `shutDown()` -when the app closes. With strict schema validation, an invalid model prevents startup. +The policy store identifies the tutorial IdP as a trusted issuer. Start the IdP +before the API so Cedarling can fetch its configuration and signing keys. With +`SYNC` loading, initialization waits for configured issuers to load. The extra +count check rejects an archive with no trusted issuer definitions: Cedarling +can initialize that archive, but P1 needs an issuer for its signed-token decisions. + +`src/server/main.ts` owns the instance, +passes its authorization functions to `buildApp()`, and awaits Cedarling and +database cleanup on shutdown. The executable then flushes its output and exits; +stalled cleanup has a four-second deadline. With strict schema validation, an +invalid model prevents startup. ### Check permission before saving a task -Now let's follow a creation request through the code we copied. Inside +Now let's follow a creation request through the code we added. Inside `createServerAuthorization()`'s returned `authorize(requestId, session, target)` function, `session` is trusted server -state and `target` describes the action and resource. This expanded `Create` -request shows what `tokenSet(session)` and `requestItem(session, target)` produce: +state and `target` describes the action and resource. The nested function uses +helpers to build the request, records the decision, and rejects policy errors: ```ts -// src/server/authorization-trace.ts -const result = await cedarling.authorizeMultiIssuer( - JSON.stringify({ - tokens: [ - { - mapping: "P1TaskManager::Access_token", - payload: session.tokens.accessToken, - }, - ], - action: 'Task::Action::"Create"', - resource: { - cedar_entity_mapping: { - entity_type: "Task::Tenant", - id: session.user.tenantId, - }, - tenant_id: session.user.tenantId, +// src/server/authorization-trace.ts, inside createServerAuthorization() +async function authorize( + requestId: string, + session: Session, + target: AuthorizationTarget, +): Promise { + try { + const result = await cedarling.authorizeMultiIssuer( + JSON.stringify({ + tokens: tokenSet(session), + ...requestItem(session, target), + }), + ); + logDecision(requestId, session, target.capability, result.request_id); + if (result.response.diagnostics.errors.length > 0) + throw new Error("Cedarling returned policy evaluation errors"); + return result.decision; + } catch (error) { + logFailure(requestId, session, [target]); + throw error; + } +} +``` + +
+What does the Create request contain? + +For creation in the user's tenant, the helpers above produce this request +object. Use it to inspect the fields passed by `authorize()`: + +```ts +// src/server/authorization-trace.ts: request passed by authorize() for Create +({ + tokens: [ + { + mapping: "P1TaskManager::Access_token", + payload: session.tokens.accessToken, }, - context: { - boundary: "server", - user: { - id: session.user.id, - subject: session.user.subject, - tenant_id: session.user.tenantId, - role: session.user.role, - assurance_level: session.user.assuranceLevel, - }, + ], + action: 'Task::Action::"Create"', + resource: { + cedar_entity_mapping: { + entity_type: "Task::Tenant", + id: session.user.tenantId, }, - }), -); -for (const log of cedarling.getLogsByRequestId(result.request_id)) { - console.info(JSON.stringify(log, null, 2)); -} -if (result.response.diagnostics.errors.length > 0) { - throw new Error("Cedarling returned policy evaluation errors"); -} -return result.decision; + tenant_id: session.user.tenantId, + }, + context: { + boundary: "server", + user: { + id: session.user.id, + subject: session.user.subject, + tenant_id: session.user.tenantId, + role: session.user.role, + assurance_level: session.user.assuranceLevel, + }, + }, +}); ``` +
+ `authorizeMultiIssuer()` expects a JSON string; `JSON.stringify()` converts the request object to that format.[^5] We use `JSON.stringify(log, null, 2)` for a -different reason: it makes nested decision logs readable in the console. For -production, review those logs and send them to access-controlled audit storage, -such as Jans Lock Server, rather than relying on console output. +different reason: it makes nested decision logs readable in the console. The module also logs `authorization.context`, linking the Fastify request ID, user, capability, and Cedarling request ID. It leaves Cedarling's own records @@ -549,28 +645,34 @@ unchanged. Unexpected errors produce one `authorization.failed` record with limited fields, without raw error messages or tokens. In the updated `POST /api/tasks` handler, authentication, CSRF, and input checks -come first. These lines in `src/server/app.ts` then wait for authorization -before writing: +come first. Inside `buildApp()`, this handler waits for authorization before +writing; the earlier validation is omitted here: ```ts // src/server/app.ts -if ( - !(await authorize( - session, - { capability: capabilities.create, tenantId: session.user.tenantId }, - reply, - 403, - )) -) - return; -const task = database.createTask( - session.user, - parsed.data.title, - parsed.data.description, +// Inside buildApp(): +app.post( + "/api/tasks", + { schema: apiSchema(capabilities.create) }, + async (request, reply) => { + // ... authenticate session, check CSRF, and validate parsed input. + if ( + !(await authorize( + session, + { capability: capabilities.create, tenantId: session.user.tenantId }, + reply, + 403, + )) + ) + return; + const task = database.createTask( + session.user, + parsed.data.title, + parsed.data.description, + ); + return reply.code(201).send({ task }); + }, ); -return reply.code(201).send({ - task, -}); ``` This route's `authorize()` helper calls the Cedarling-backed authorization @@ -600,7 +702,9 @@ pnpm start On startup, you should see the policy version and SHA-256. `/health` should return `{ "status": "ok" }`. Sign in as Alex, reload, and repeat the original `Create` request: expect **403**, with no new task. In a separate Mina session, -the same request with a valid title returns **201**. The baseline-created +the same request with a valid title returns **201**. Use **Change account** to +sign out before switching users, or use separate browser profiles. Two ordinary +tabs share a session. Fetch a new CSRF token after signing in. The baseline-created task may still exist. We haven't changed the browser buttons yet, so they still offer operations the API now denies. We'll fix that next. @@ -612,7 +716,13 @@ permissions too.
Required step: Update browser authorization and controls -Replace these existing files with their complete linked contents: +Run: + +```bash +node ../shared/tools/step/run.mjs p1 browser +``` + +This connects browser evaluation to the API client and React controls: - [`src/web/authorization-trace.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/src/web/authorization-trace.ts) checks the archive and evaluates browser permissions. - [`src/web/api.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/src/web/api.ts) fetches tasks and their authorization envelope. @@ -626,7 +736,8 @@ The server returns a list of actions it currently allows: its **decision ceiling For Alex, that includes editing his assigned brief, but not creating a task. Browser Cedarling can remove actions from this list, but cannot add permission to create. -An **authorization envelope** carries that list, task data, safe user fields, +An **authorization envelope** accompanies the task data in the API response. +It carries the allowed-action list, safe user fields, policy ID/version/SHA-256/archive URL, subject epoch, resource versions, evaluation time, and expiry. The exact archive is served at `/policy-store/.cjar`. OAuth tokens and session IDs stay on the server. @@ -634,23 +745,34 @@ versions, evaluation time, and expiry. The exact archive is served at The subject epoch is a value used to detect changes in the user or their permissions. A change in user or role requires the browser to refresh its controls. -In `src/web/authorization-trace.ts`, the loader fetches the archive from the -app's origin and checks its SHA-256 hash. It then passes the verified `bytes` to -Cedarling: +In `src/web/authorization-trace.ts`, `loadPolicy()` fetches the archive from the +app's origin and checks its SHA-256 hash. Its asynchronous loader passes the +verified `bytes` to Cedarling: ```ts // src/web/authorization-trace.ts -const cedarling = await initFromArchiveBytes( - { - CEDARLING_APPLICATION_NAME: "P1 Task Manager browser", - CEDARLING_LOG_TYPE: "memory", - CEDARLING_LOG_TTL: 300, - CEDARLING_JWT_SIG_VALIDATION: "disabled", - CEDARLING_JWT_STATUS_VALIDATION: "disabled", - CEDARLING_STRICT_SCHEMA_VALIDATION: "enabled", - }, - bytes, -); +async function loadPolicy( + policy: AuthorizationEnvelope["policy"], +): Promise { + // ... reuse an existing instance and validate the archive URL. + const promise = (async () => { + // ... fetch bytes and verify their SHA-256 against policy.sha256. + const cedarling = await initFromArchiveBytes( + { + CEDARLING_APPLICATION_NAME: "P1 Task Manager browser", + CEDARLING_LOG_TYPE: "memory", + CEDARLING_LOG_TTL: 300, + CEDARLING_JWT_SIG_VALIDATION: "disabled", + CEDARLING_JWT_STATUS_VALIDATION: "disabled", + CEDARLING_STRICT_SCHEMA_VALIDATION: "enabled", + }, + bytes, + ); + // ... store this instance and close the previous one. + return active; + })(); + // ... track the pending load, await promise, and clear pending state. +} ``` Both JWT checks are disabled only in this unsigned browser instance. It receives @@ -659,13 +781,26 @@ no JWTs and needs no IdP discovery; the server's validation remains enabled. The browser builds `principal` as `Task::User` from user facts checked against `envelope.uiPrincipal`. Each item uses the shared action mapping, the resource fields sent by the server, and `context: { boundary: "browser" }`. Assignment -also supplies the assignee's tenant from the server. The browser evaluates these requests together: +also supplies the assignee's tenant from the server. Inside +`authorizePresentation()`, it evaluates these requests together: ```ts -// src/web/authorization-trace.ts +// src/web/authorization-trace.ts, inside authorizePresentation()'s try block +const { cedarling } = await loadPolicy(envelope.policy); +// ... recheck envelope freshness after the asynchronous load. const batch = await cedarling.authorizeUnsignedBatch( - JSON.stringify({ principal, items }), + JSON.stringify({ + principal: { + cedar_entity_mapping: { entity_type: "Task::User", id: user.id }, + id: user.id, + tenant_id: user.tenantId, + role: user.role, + assurance_level: user.assuranceLevel, + }, + items, + }), ); +// ... check batch results and narrow the server ceiling. ``` The browser checks each item's success and diagnostics. It enables a control @@ -699,35 +834,44 @@ startup and Docker builds prepare the same policy archive automatically.
Required step: Update setup, development startup, and Docker packaging -Replace these existing files with their complete linked contents: +Run: + +```bash +node ../shared/tools/step/run.mjs p1 startup +``` + +This updates: - [`scripts/setup.mjs`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/scripts/setup.mjs) prepares configuration and builds the archive. - [`scripts/dev.mjs`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/scripts/dev.mjs) starts the IdP, browser watcher, and API together. - [`Dockerfile`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/Dockerfile) builds and includes the policy archive in the image. - [`shared/dev-supervisor.mjs`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/shared/dev-supervisor.mjs), at repository root, manages development processes and readiness checks. -
- -Update only these two entries in -[`package.json`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/package.json)'s existing `scripts` object; -retain the other scripts and installed dependencies: +The step also updates `package.json`'s `build` script to include the archive +builder and its `dev` script to use the supervisor. Other scripts and installed +dependencies are preserved. -```json -{ - "scripts": { - "build": "node ../shared/policy-store.mjs && vite build && tsc -p tsconfig.server.json", - "dev": "node scripts/dev.mjs" - } -} -``` +
Run `pnpm build` to produce the archive, browser bundle, and compiled server. -Stop both native terminals before switching to the supervisor or Docker. +Stop both native terminals before starting the development supervisor. Keep the configured issuer and API audience consistent with the policy store. The completed `scripts/dev.mjs` prepares the project and starts its IdP, -browser watcher, and API together. With dependencies installed, run `pnpm dev`; -Docker still uses `docker compose up --build`. We no longer need the baseline's -two-terminal startup. +browser watcher, and API together. With dependencies installed, run `pnpm dev`. + +
+Optional: Run the integrated app with Docker + +Stop the native stack first, then run: + +```bash +docker compose up --build +``` + +Docker builds the policy archive from the same source files. It uses its own +data volumes; sign in again after switching. + +
Troubleshooting startup and request errors @@ -748,16 +892,22 @@ with fresh data, then check the remaining actions and their decision logs. ### Retry Alex's request, then edit his assigned task
-If needed: Reset the sample data in this checkout +If needed: Reset the native sample data Stop the exercise services first. For native execution, run `pnpm reset`, then `pnpm dev` from this checkout's `p1-task-manager/`. Reset removes `.data`, including SQLite tasks, sessions, and stored policy artifacts; it preserves -`.env` and `.local/idp/.env`. Startup recreates fixtures. Sign in again. +`.env` and `.local/idp/.env`. Startup recreates fixtures. Sign in again. Do not +reset data you want to keep. + +
+ +
+If using Docker: Reset the sample data For this checkout's Docker stack, run `docker compose down --volumes`, then `docker compose up --build`. This deletes its data and generated app/IdP -configuration volumes. Do not use either reset against data you want to keep. +configuration volumes. Do not reset data you want to keep. Sign in again afterward.
@@ -783,13 +933,123 @@ With the original sample data, check every row in this table: | `Complete` | `DENY` | `ALLOW` related A task | `DENY` | | `Delete` | `DENY` | `ALLOW` owned A task | `DENY` | -Sign in separately as each person. Changes affect later requests: deleting a -task makes it absent, and submitting an old version causes 409. Keep these -workflow checks separate from permission denials. +Use a separate session for each person, or sign out between accounts. The +requests below let us exercise denied operations even when their controls are hidden. + +
+Required step: Exercise the permission matrix through the API + +Run these requests in the browser console on `http://localhost:17001` while +signed in as the account being checked. The sample records are: + +| Task ID | Tenant | Owner | Assignee | +| --------------- | ------ | ----------- | ----------- | +| `task-a-brief` | A | `user-mina` | `user-alex` | +| `task-a-review` | A | `user-mina` | `user-mina` | +| `task-b-notes` | B | `user-sam` | `user-sam` | + +Paste this helper once in each session. It gets that session's CSRF token for +writes and prints the response without printing credentials: + +```js +// Browser console on the task manager page +async function taskRequest(method, path, body) { + const sessionResponse = await fetch("/api/session"); + if (!sessionResponse.ok) throw new Error("Sign in before testing"); + const { csrfToken } = await sessionResponse.json(); + const response = await fetch(path, { + method, + headers: + body === undefined + ? {} + : { + "content-type": "application/json", + "x-csrf-token": csrfToken, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const result = await response.json(); + console.log(response.status, result); + return result; +} +``` + +For each account, use these calls to check `View`: + +```js +await taskRequest("GET", "/api/tasks/task-a-brief"); +await taskRequest("GET", "/api/tasks/task-a-review"); +await taskRequest("GET", "/api/tasks/task-b-notes"); +``` + +Alex sees only the brief, Mina sees both A tasks, and Sam sees only his B task. +Allowed reads return **200**; the other reads return **404**. Repeat the original +`Create` request for each account: Mina gets **201**, Alex and Sam get **403**. + +For an allowed read, take the current `task.version` before every write. For +example, Alex can edit his brief with: + +```js +var currentTask = (await taskRequest("GET", "/api/tasks/task-a-brief")).task; +await taskRequest("PATCH", "/api/tasks/task-a-brief", { + title: "Updated launch brief", + description: currentTask.description, + version: currentTask.version, +}); +``` + +Use the same pattern for Mina's A tasks and Sam's B task. These edits return +**200**. To check Sam's denied cross-tenant edit, send a valid body to +`/api/tasks/task-a-brief` while signed in as Sam. Use the version last read by +Alex or Mina; Sam cannot read that protected record. Expect **404**, then verify +as Mina that the title and version did not change. + +For the remaining requests, set `var taskId = "task-a-brief"` (or +`"task-b-notes"` for Sam's own task) in the console. Before each write to a +readable task, run: + +```js +var version = (await taskRequest("GET", "/api/tasks/" + taskId)).task.version; +``` + +For a task that this account cannot read, set `var version` to the version +obtained in an authorized session. Run the request shapes below one at a time, +following the account order afterward: + +```js +await taskRequest("POST", `/api/tasks/${taskId}/assign`, { + assigneeId: "user-alex", + version, +}); +await taskRequest("POST", `/api/tasks/${taskId}/complete`, { version }); +await taskRequest("DELETE", `/api/tasks/${taskId}`, { version }); +``` + +1. As Alex, use `taskId = "task-a-brief"` and its current version. `Assign`, + `Complete`, and `Delete` each return **404**. Read the task again to confirm + that it is unchanged. +2. As Sam, repeat those three requests for `task-b-notes`, which he can read. + Each returns **404**. Repeat them against `task-a-brief` using its version + from an authorized session; they also return **404**. Verify the A task as Mina. +3. As Mina, assign `task-a-brief` to `user-alex`: expect **200**. Read its new + version, then try `assigneeId: "user-sam"`: expect **404** and no change. +4. Finish as Mina by completing `task-a-brief` (**200**), reading its new + version, and deleting it (**200**). Do these last so earlier checks still + have a task to inspect. A subsequent read returns **404** because it is now gone. + +The API uses **404** for both denied and absent tasks. Confirm the record exists +with its owner before treating 404 as evidence of a permission check. A **409** +means an allowed write used an old version; **400** means the input is invalid. +Neither is an authorization denial. Reset the disposable sample data if you +want to repeat this sequence. + +
### Read the decision logs We've checked the responses; the logs let us connect them to the policies. +Read server decisions in the running API terminal, or the application's Compose +output. Browser decisions appear in the browser's developer console. Formatted JSON keeps nested reasons readable. Match the application record's `cedarlingRequestId` to Cedarling's `request_id`. One HTTP request can produce several decisions, each with its own Cedarling ID. @@ -829,17 +1089,59 @@ before sharing them. ### Test stale permissions and authorization failures -The finished project's tests also cover permissions that change while a request -is pending. Run them in a separate checkout, which includes the integration -tests alongside the runtime code. Stop P1 services to free ports 17001 and -18001, then use a separate directory for the finished example: +The manual requests checked our main workflow. We'll now add the integration's +tests to this same checkout, including cases that are awkward to reproduce by +hand: expired controls, delayed responses, and unavailable authorization. + +
+Required step: Add the matching tests and check configuration + +Add the checks and install their browser test runner: + +```bash +node ../shared/tools/step/run.mjs p1 checks +pnpm add --save-dev --save-exact @playwright/test@1.63.0 +``` + +The step updates the baseline's API, setup, and React tests: + +- [`test/app.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/test/app.test.ts) checks route enforcement and failures with controlled authorization results. +- [`test/setup.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/test/setup.test.ts) checks configuration and archive preparation. +- [`test/web-app.test.tsx`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/test/web-app.test.tsx) checks controls, expiry, and delayed responses. + +It also adds these integration checks: + +- [`test/policy-store.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/test/policy-store.test.ts) runs signed and unsigned requests through real Cedarling policies. +- [`test/browser-authorization.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/test/browser-authorization.test.ts) checks freshness and fallback with a controlled browser runtime. +- [`test/config.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/test/config.test.ts) checks the fixed issuer and audience configuration. +- [`test/server-lifecycle.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/test/server-lifecycle.test.ts) checks process shutdown with the pinned Cedarling package. +- [`e2e/task-authorization.e2e.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/e2e/task-authorization.e2e.ts) repeats sign-in and permission checks in Chromium with the real IdP. +- [`playwright.config.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/playwright.config.ts) selects that browser test and its startup helper. +- [`shared/browser-test-stack.mjs`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/shared/browser-test-stack.mjs), at repository root, starts the test app and IdP with disposable data and closes them afterward. + +The updated [`tsconfig.json`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/tsconfig.json), +[`tsconfig.lint.json`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/tsconfig.lint.json), +[`eslint.config.js`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/eslint.config.js), +and [`.prettierignore`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/.prettierignore) +include shared types, tests, and browser test configuration while ignoring +generated output. The step keeps the other baseline tests and adds browser +verification to the `check` and `test:e2e` package scripts. + +
+ +In `test/policy-store.test.ts`, find `create a task as an assured owner`. +Compare Mina's token and owner/assurance facts with Alex's `contributor` role +and level 1 from our manual request. The policy tests also vary token scope, +tenant, and task relationships. Together, the tests check that decision logs +stay linked by request ID without token payloads, and that unavailable +authorization returns 503 without changing protected data. + +Stop P1 and its IdP to free ports 17001 and 18001. From the same checkout's +`p1-task-manager/`, run: ```bash -git clone --branch main https://github.com/GluuFederation/cedarling-tutorials.git cedarling-p1-finished -cd cedarling-p1-finished/p1-task-manager -pnpm --dir ../shared/identity-provider install --frozen-lockfile -pnpm install --frozen-lockfile pnpm exec playwright install chromium +pnpm format pnpm check ``` @@ -847,45 +1149,29 @@ On Linux, missing browser libraries may require `pnpm exec playwright install --with-deps chromium`. The check includes formatting, lint, types, unit tests, build, and real browser/IdP verification with disposable data. -These tests exercise actual Cedarling policies for token, scope, tenant, and -relationship failures. They check that logs stay readable and linked by request ID without -token payloads, and that unavailable authorization returns 503 without changing -protected data. - -Browser checks cover stale envelopes and fallback to a current server ceiling. -They also repeat the unauthorized direct `Create` request with the real IdP and -browser Cedarling instance.[^7] +The browser test also checks that the direct `Create` request is denied while +the real browser Cedarling instance evaluates controls. The full command must +pass in the checkout we've just assembled. A runtime failure needs its own error event, distinct from a policy denial, and must stop the protected operation. Stopping the IdP alone won't reliably simulate a PDP failure because signing keys and valid tokens may already be cached.
-Warning: Before deploying this application - -Local HTTP and the bundled IdP's sample passwords are for learning only. -Production needs real authentication and assurance, HTTPS, protected secrets, -reviewed policy delivery, and rules for who can read logs and how long to keep them. This local -development IdP and console output are not a production identity or audit system. - -Use a configured OIDC/OAuth issuer, such as [Jans Auth](https://docs.jans.io/stable/janssen-server/planning/use-cases/), -Gluu, Auth0, or Okta, rather than deploying the tutorial IdP. - -For a production stack, consider Agama Lab Policy Designer for policy authoring, -Jans Auth for issuing tokens, and Lock Server for centralized decision logs. -See [Cedarling production solutions](https://cedarling.dev/solutions). +Warning: Learning project only -These steps were prepared on Ubuntu 24.04+. Native project checks also run in CI -on macOS and Windows. If a platform-specific step fails, -[open an issue](https://github.com/GluuFederation/cedarling-tutorials/issues). +This project is for learning only and is not intended for production use. +Local HTTP, the tutorial IdP, and seeded assurance values are learning fixtures. +Adapting the app requires a separate security and operational review, including +authentication, policy delivery, secret management, and log access and retention. -For further reference, use the official -[Cedar policy syntax](https://docs.cedarpolicy.com/policies/syntax-policy.html) and -[Cedar schema syntax](https://docs.cedarpolicy.com/schema/human-readable-schema.html). +Optional components include Jans Auth for identity, Agama Lab Policy Designer +for policy authoring, and Lock Server for decision logs. See +[Cedarling production solutions](https://cedarling.dev/solutions).
-## What we've learned +## Recap: task permissions We started with Alex creating tasks meant to be created by Mina. We've now blocked that request while keeping his assigned-task edits working. The API @@ -908,10 +1194,10 @@ their text reaches an AI model. [^3]: RBAC means role-based access control; ReBAC means relationship-based access control. P1 also checks tenant and assurance attributes, so neither label alone describes its decision. -[^4]: [Default entities](https://docs.jans.io/stable/cedarling/reference/cedarling-policy-store/#default-entities) are shared across requests. P1 loads its changing users and tasks for each decision; it has no reusable policy slots or non-JWT issuer to configure. +[^4]: [Default entities](https://docs.jans.io/stable/cedarling/reference/cedarling-policy-store/#default-entities) are shared across requests. P1 loads its changing users and tasks from the database for each decision. [^5]: The pinned [`cedarling_wasm` JavaScript API](https://www.npmjs.com/package/@janssenproject/cedarling_wasm/v/0.0.468) accepts a JSON-string request for token-based and unsigned authorization. Converting to JSON does not make browser-supplied facts trustworthy. [^6]: Cedar returns an empty list of determining policies when no policy permits or forbids the request. A matched `forbid` would instead appear as a determining policy. See [How Cedar authorization works](https://docs.cedarpolicy.com/auth/authorization.html). -[^7]: Full verification examples: [`test/policy-store.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/test/policy-store.test.ts), [`test/app.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/test/app.test.ts), [`test/browser-authorization.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/test/browser-authorization.test.ts), and [`e2e/task-authorization.e2e.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p1-task-manager/e2e/task-authorization.e2e.ts). +[^7]: These steps were prepared on Ubuntu 24.04+. Native project checks also run in CI on macOS and Windows. If a platform-specific step fails, [open an issue](https://github.com/GluuFederation/cedarling-tutorials/issues). diff --git a/p1-task-manager/scripts/dev.mjs b/p1-task-manager/scripts/dev.mjs index c0e866e..0508245 100644 --- a/p1-task-manager/scripts/dev.mjs +++ b/p1-task-manager/scripts/dev.mjs @@ -1,5 +1,6 @@ /** Prepare and supervise P1's IdP, browser build watcher, and API together. */ import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; import { resolve } from "node:path"; import { parseEnv } from "node:util"; import { issuerHealth, runDevStack } from "../../shared/dev-supervisor.mjs"; @@ -8,6 +9,7 @@ import { loadConfig } from "../src/server/config.ts"; const root = resolve(import.meta.dirname, ".."); const identityRoot = resolve(root, "../shared/identity-provider"); const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; +const tsx = createRequire(import.meta.url).resolve("tsx/cli"); try { await runDevStack({ @@ -44,8 +46,8 @@ try { }, { name: "P1 application", - command: pnpm, - args: ["exec", "tsx", "watch", "src/server/main.ts"], + command: process.execPath, + args: [tsx, "watch", "src/server/main.ts"], cwd: root, env, health: { diff --git a/p1-task-manager/src/server/authorization-trace.ts b/p1-task-manager/src/server/authorization-trace.ts index 710c978..c004bbe 100644 --- a/p1-task-manager/src/server/authorization-trace.ts +++ b/p1-task-manager/src/server/authorization-trace.ts @@ -180,7 +180,7 @@ export async function createServerAuthorization( ); if (cedarling.loadedTrustedIssuersCount() < 1) { await cedarling.shutDown(); - throw new Error("P1 trusted issuer did not load"); + throw new Error("P1 requires at least one trusted issuer"); } const policy = { diff --git a/p1-task-manager/src/server/main.ts b/p1-task-manager/src/server/main.ts index 4f77f27..1946a05 100644 --- a/p1-task-manager/src/server/main.ts +++ b/p1-task-manager/src/server/main.ts @@ -1,4 +1,4 @@ -import { existsSync } from "node:fs"; +import { existsSync, writeSync } from "node:fs"; import path from "node:path"; import { loadProjectEnvironment } from "./environment.js"; import { buildApp } from "./app.js"; @@ -48,9 +48,43 @@ try { } console.log(`P1 Task Manager listening at ${config.baseUrl}`); +let stopping = false; + async function shutDown(signal: string): Promise { + if (stopping) return; + stopping = true; + // Finish before the development supervisor's five-second shutdown limit. + const deadline = setTimeout(() => { + writeSync(2, "P1 shutdown timed out\n"); + process.exit(1); + }, 4_000); console.log(`Received ${signal}; stopping P1 Task Manager`); - await app?.close(); + let exitCode = 0; + try { + await app?.close(); + } catch { + console.error("P1 shutdown failed"); + exitCode = 1; + } + try { + await Promise.all( + [process.stdout, process.stderr].map( + (stream) => + new Promise((resolve, reject) => { + stream.write("", (error) => { + if (error) reject(error); + else resolve(); + }); + }), + ), + ); + } catch { + exitCode = 1; + } + clearTimeout(deadline); + // The pinned WASM runtime can retain timers after shutDown() resolves. + // Only this executable exits; reusable authorization code just closes resources. + process.exit(exitCode); } -process.once("SIGINT", () => void shutDown("SIGINT")); -process.once("SIGTERM", () => void shutDown("SIGTERM")); +process.on("SIGINT", () => void shutDown("SIGINT")); +process.on("SIGTERM", () => void shutDown("SIGTERM")); diff --git a/p1-task-manager/src/web/App.tsx b/p1-task-manager/src/web/App.tsx index 5ee06db..2c39232 100644 --- a/p1-task-manager/src/web/App.tsx +++ b/p1-task-manager/src/web/App.tsx @@ -205,6 +205,7 @@ function Workspace({ const [detailExpiresAt, setDetailExpiresAt] = useState(); const createTriggerRef = useRef(null); const deleteTriggerRef = useRef(null); + const listRequestRef = useRef(0); const replaceTask = useCallback((task: Task) => { setTasks((current) => { @@ -217,15 +218,20 @@ function Workspace({ }, []); const loadTasks = useCallback(async () => { + const request = ++listRequestRef.current; + const isCurrent = () => request === listRequestRef.current; + setCreateAllowed(false); setListState("loading"); setListError(""); try { const result = await api.tasks(); + if (!isCurrent()) return; const presentation = await authorizePresentation({ envelope: result.authorization, tasks: result.tasks, user: session.user, }); + if (!isCurrent()) return; if (presentation.stale) throw new Error("The authorization state changed"); const visibleTasks = result.tasks.filter( @@ -242,6 +248,7 @@ function Workspace({ ); setListState("ready"); } catch (error) { + if (!isCurrent()) return; if (error instanceof ApiError && error.status === 401) { onSessionExpired(); return; @@ -253,6 +260,9 @@ function Workspace({ useEffect(() => { void loadTasks(); + return () => { + listRequestRef.current += 1; + }; }, [loadTasks]); useEffect(() => { @@ -414,6 +424,7 @@ function Workspace({ event: SyntheticEvent, ) { event.preventDefault(); + if (!createAllowed) return; setBusy(true); setCreateError(""); try { @@ -819,7 +830,7 @@ function Workspace({ > Cancel - diff --git a/p1-task-manager/src/web/authorization-trace.ts b/p1-task-manager/src/web/authorization-trace.ts index 378f01b..0938e44 100644 --- a/p1-task-manager/src/web/authorization-trace.ts +++ b/p1-task-manager/src/web/authorization-trace.ts @@ -208,6 +208,8 @@ export async function authorizePresentation( try { const { cedarling } = await loadPolicy(envelope.policy); + if (!isCurrent(envelope, tasks, user, options.expectedSubjectEpoch)) + return { ceiling: emptyCeiling(), stale: true }; const batch = await cedarling.authorizeUnsignedBatch( JSON.stringify({ principal: { @@ -250,8 +252,12 @@ export async function authorizePresentation( Boolean(controls[destination.control]) && result.decision; } } + if (!isCurrent(envelope, tasks, user, options.expectedSubjectEpoch)) + return { ceiling: emptyCeiling(), stale: true }; return { ceiling, stale: false }; } catch { + if (!isCurrent(envelope, tasks, user, options.expectedSubjectEpoch)) + return { ceiling: emptyCeiling(), stale: true }; console.warn( "P1 browser | authorization unavailable; using the current server ceiling", ); diff --git a/p1-task-manager/test/browser-authorization.test.ts b/p1-task-manager/test/browser-authorization.test.ts index 186725f..2d507d5 100644 --- a/p1-task-manager/test/browser-authorization.test.ts +++ b/p1-task-manager/test/browser-authorization.test.ts @@ -1,7 +1,7 @@ /** @vitest-environment jsdom */ import { createHash, webcrypto } from "node:crypto"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import type { AuthorizationEnvelope } from "../src/shared/authorization"; +import type { AuthorizationEnvelope } from "../src/shared/authorization.js"; import type { Task, User } from "../src/web/types"; const sdk = vi.hoisted(() => ({ initFromArchiveBytes: vi.fn() })); @@ -148,6 +148,19 @@ describe("P1 browser Cedarling boundary", () => { warning.mockRestore(); }); + test("does not fall back to a ceiling that expired during initialization", async () => { + const current = envelope(); + const now = vi.spyOn(Date, "now"); + sdk.initFromArchiveBytes.mockImplementation(async () => { + now.mockReturnValue(Date.parse(current.expiresAt)); + throw new Error("Browser initialization failed after expiry"); + }); + + expect( + await authorizePresentation({ envelope: current, tasks: [task], user }), + ).toEqual({ stale: true, ceiling: { tasks: {} } }); + }); + test("skips browser evaluation when the server ceiling denies every control", async () => { const current = envelope(); const denied = { @@ -164,6 +177,30 @@ describe("P1 browser Cedarling boundary", () => { expect(sdk.initFromArchiveBytes).not.toHaveBeenCalled(); }); + test.each(["initialization", "evaluation"])( + "discards permissions that expire during successful %s", + async (stage) => { + const current = envelope(); + const now = vi.spyOn(Date, "now"); + const runtime = cedarling([true, true, true]); + const evaluate = runtime.authorizeUnsignedBatch.getMockImplementation()!; + runtime.authorizeUnsignedBatch.mockImplementation(async () => { + if (stage === "evaluation") + now.mockReturnValue(Date.parse(current.expiresAt)); + return evaluate(); + }); + sdk.initFromArchiveBytes.mockImplementation(async () => { + if (stage === "initialization") + now.mockReturnValue(Date.parse(current.expiresAt)); + return runtime; + }); + + expect( + await authorizePresentation({ envelope: current, tasks: [task], user }), + ).toEqual({ stale: true, ceiling: { tasks: {} } }); + }, + ); + test("rejects an envelope whose resource version is stale", async () => { const result = await authorizePresentation({ envelope: envelope(), diff --git a/p1-task-manager/test/policy-store.test.ts b/p1-task-manager/test/policy-store.test.ts index 08cd699..7f31ede 100644 --- a/p1-task-manager/test/policy-store.test.ts +++ b/p1-task-manager/test/policy-store.test.ts @@ -639,6 +639,37 @@ describe("P1 browser shadow policy", () => { }); describe("P1 server Cedarling boundary", () => { + test("rejects a policy archive without trusted issuer definitions", async () => { + const root = await mkdtemp(join(tmpdir(), "cedarling-p1-no-issuers-")); + let runtime: + Awaited> | undefined; + try { + await cp(policyStoreSource, join(root, "policy-store"), { + recursive: true, + filter: (source) => + source !== join(policyStoreSource, "trusted-issuers"), + }); + await buildPolicyStore({ + projectRoot: root, + dependencyRoot: projectRoot, + }); + const initialization = createServerAuthorization({ + projectRoot: root, + dataDirectory: join(root, "data"), + }).then((value) => { + runtime = value; + return value; + }); + + await expect(initialization).rejects.toThrow( + "P1 requires at least one trusted issuer", + ); + } finally { + await runtime?.close(); + await rm(root, { recursive: true, force: true }); + } + }); + test("authorizes single and batch requests from the built archive", async () => { const info = vi.spyOn(console, "info").mockImplementation(() => {}); const error = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/p1-task-manager/test/server-lifecycle.test.ts b/p1-task-manager/test/server-lifecycle.test.ts new file mode 100644 index 0000000..eaf57a2 --- /dev/null +++ b/p1-task-manager/test/server-lifecycle.test.ts @@ -0,0 +1,202 @@ +import { spawn } from "node:child_process"; +import { generateKeyPairSync } from "node:crypto"; +import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { createServer, type Server } from "node:http"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { afterAll, beforeAll, expect, test } from "vitest"; +import { buildPolicyStore } from "../../shared/policy-store.mjs"; + +const projectRoot = fileURLToPath(new URL("..", import.meta.url)); +const entry = fileURLToPath(new URL("../src/server/main.ts", import.meta.url)); +const loader = pathToFileURL( + createRequire(import.meta.url).resolve("tsx"), +).href; +let root: string; +let issuer: Server | undefined; + +beforeAll(async () => { + const jwk = generateKeyPairSync("rsa", { + modulusLength: 2048, + }).publicKey.export({ format: "jwk" }); + issuer = createServer((request, response) => { + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify( + request.url === "/jwks" + ? { + keys: [{ ...jwk, kid: "lifecycle", use: "sig", alg: "RS256" }], + } + : { + issuer: "http://localhost:18001", + jwks_uri: "http://localhost:18001/jwks", + authorization_endpoint: "http://localhost:18001/auth", + token_endpoint: "http://localhost:18001/token", + }, + ), + ); + }); + await new Promise((resolve, reject) => { + issuer!.once("error", reject); + issuer!.listen(18001, resolve); + }); + root = await mkdtemp(join(tmpdir(), "p1-lifecycle-")); + await cp(join(projectRoot, "policy-store"), join(root, "policy-store"), { + recursive: true, + }); + await buildPolicyStore({ projectRoot: root, dependencyRoot: projectRoot }); + await mkdir(join(root, "dist/web"), { recursive: true }); + await writeFile( + join(root, "dist/web/index.html"), + "P1 lifecycle", + ); +}, 30_000); + +afterAll(async () => { + issuer?.closeAllConnections(); + if (issuer?.listening) + await new Promise((resolve) => { + issuer!.close(() => resolve()); + }); + if (root) await rm(root, { recursive: true, force: true }); +}); + +async function stopApplication(signal: string, mode = "normal") { + // Fastify's public diagnostics channel lets a test plugin delay or fail cleanup. + const script = ` + import { subscribe } from 'node:diagnostics_channel'; + import { pathToFileURL } from 'node:url'; + subscribe('fastify.initialization', ({ fastify }) => { + fastify.addHook('onClose', async () => { + console.log('lifecycle-close-start'); + if (process.env.LIFECYCLE_MODE === 'hang') await new Promise(() => {}); + if (process.env.LIFECYCLE_MODE === 'error') throw new Error('private-cleanup-detail'); + await new Promise(resolve => setTimeout(resolve, 100)); + if (process.env.LIFECYCLE_MODE === 'output') { + process.stdout.write('x'.repeat(256 * 1024) + 'lifecycle-output-finished\\n'); + } + console.log('lifecycle-close-finished'); + }); + }); + await import(pathToFileURL(process.env.LIFECYCLE_ENTRY).href); + console.log('lifecycle-ready'); + process.on('message', () => { + process.emit(process.env.LIFECYCLE_SIGNAL, process.env.LIFECYCLE_SIGNAL); + if (process.env.LIFECYCLE_MODE === 'repeat') { + process.emit('SIGINT', 'SIGINT'); + process.emit('SIGTERM', 'SIGTERM'); + } + process.disconnect(); + }); + `; + const child = spawn( + process.execPath, + ["--import", loader, "--input-type=module", "-e", script], + { + cwd: root, + env: { + ...process.env, + P1_PROJECT_ROOT: root, + P1_DATA_DIR: ".data", + P1_HOST: "127.0.0.1", + P1_PORT: "17001", + P1_BASE_URL: "http://localhost:17001", + P1_ISSUER: "http://localhost:18001", + P1_API_RESOURCE: "http://localhost:17001/api", + P1_CLIENT_ID: "p1-task-manager", + P1_CLIENT_SECRET: "lifecycle-only-secret".repeat(3), + P1_SESSION_ENCRYPTION_KEY: Buffer.alloc(32, 8).toString("base64url"), + LIFECYCLE_ENTRY: entry, + LIFECYCLE_SIGNAL: signal, + LIFECYCLE_MODE: mode, + }, + stdio: ["ignore", "pipe", "pipe", "ipc"], + }, + ); + let output = ""; + let error = ""; + let ready = false; + let timedOut = false; + const startupTimeout = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, 10_000); + let shutdownTimeout: ReturnType | undefined; + child.stdout!.on("data", (chunk: Buffer) => { + output += chunk.toString(); + if (!ready && output.includes("lifecycle-ready")) { + ready = true; + clearTimeout(startupTimeout); + shutdownTimeout = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, 5_000); + // POSIX runs also cover delivery of actual operating-system signals. + if (process.platform !== "win32" && mode === "normal") { + child.disconnect(); + child.kill(signal as NodeJS.Signals); + } else child.send("stop"); + } + }); + child.stderr!.on("data", (chunk: Buffer) => { + error += chunk.toString(); + }); + const code = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", resolve); + }).finally(() => { + clearTimeout(startupTimeout); + clearTimeout(shutdownTimeout); + }); + return { code, output, error, ready, timedOut }; +} + +test.each(["SIGINT", "SIGTERM"])( + "P1 exits after %s with a real Cedarling instance", + async (signal) => { + const result = await stopApplication(signal); + expect(result.ready, result.error).toBe(true); + expect(result.output).toContain("lifecycle-close-finished"); + expect(result.timedOut).toBe(false); + expect(result.code).toBe(0); + }, + 20_000, +); + +test("repeated shutdown signals wait for one cleanup", async () => { + const result = await stopApplication("SIGTERM", "repeat"); + expect(result.ready, result.error).toBe(true); + expect(result.output.match(/lifecycle-close-start/g)).toHaveLength(1); + expect(result.output).toContain("lifecycle-close-finished"); + expect(result.timedOut).toBe(false); + expect(result.code).toBe(0); +}, 20_000); + +test("shutdown flushes pending output before exiting", async () => { + const result = await stopApplication("SIGTERM", "output"); + expect(result.output).toContain( + "x".repeat(256 * 1024) + "lifecycle-output-finished", + ); + expect(result.timedOut).toBe(false); + expect(result.code).toBe(0); +}, 20_000); + +test("cleanup failure exits unsuccessfully without exposing the error", async () => { + const result = await stopApplication("SIGTERM", "error"); + expect(result.ready, result.error).toBe(true); + expect(result.error).toContain("P1 shutdown failed"); + expect(result.error).not.toContain("private-cleanup-detail"); + expect(result.timedOut).toBe(false); + expect(result.code).toBe(1); +}, 20_000); + +test("stalled cleanup exits within the shutdown deadline", async () => { + const result = await stopApplication("SIGTERM", "hang"); + expect(result.ready, result.error).toBe(true); + expect(result.output).not.toContain("lifecycle-close-finished"); + expect(result.error).toContain("P1 shutdown timed out"); + expect(result.timedOut).toBe(false); + expect(result.code).toBe(1); +}, 20_000); diff --git a/p1-task-manager/test/web-app.test.tsx b/p1-task-manager/test/web-app.test.tsx index 499bfe5..75576df 100644 --- a/p1-task-manager/test/web-app.test.tsx +++ b/p1-task-manager/test/web-app.test.tsx @@ -183,6 +183,7 @@ describe("P1 task UI", () => { afterEach(() => { act(() => root.unmount()); + vi.useRealTimers(); vi.unstubAllGlobals(); Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: false }); vi.restoreAllMocks(); @@ -269,6 +270,168 @@ describe("P1 task UI", () => { expect(button("Delete").disabled).toBe(false); }); + it("removes expired creation permission when list refresh fails", async () => { + vi.useFakeTimers(); + const mina = tutorialSessions[1]![1]; + api.session.mockResolvedValue(mina); + authorizeSession(mina, { view: true, edit: true }, true); + renderApp(root); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(button("New task").disabled).toBe(false); + + api.tasks.mockRejectedValue(new Error("List refresh failed")); + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + }); + + expect(document.body.textContent).toContain("Task service unavailable"); + expect( + Array.from(document.querySelectorAll("button")).some( + (item) => item.textContent.trim() === "New task", + ), + ).toBe(false); + }); + + it("does not restore permissions from an older task-list response", async () => { + const mina = tutorialSessions[1]![1]; + api.session.mockResolvedValue(mina); + api.tasks.mockRejectedValueOnce(new Error("Retry the list")); + renderApp(root); + await settle(); + const older = { + tasks: [task], + authorization: envelope(mina, { view: true }, true), + }; + let finishOlder!: (result: typeof older) => void; + api.tasks.mockReturnValueOnce( + new Promise((resolve) => { + finishOlder = resolve; + }), + ); + api.tasks.mockResolvedValueOnce({ + tasks: [], + authorization: envelope(mina, {}, false), + }); + const retry = button("Retry"); + act(() => { + retry.click(); + retry.click(); + }); + await settle(); + await act(async () => { + finishOlder(older); + }); + + expect( + Array.from(document.querySelectorAll("button")).some( + (item) => item.textContent.trim() === "New task", + ), + ).toBe(false); + expect(document.body.textContent).not.toContain(task.title); + }); + + it("disables an already-open create form when its permission expires", async () => { + vi.useFakeTimers(); + const mina = tutorialSessions[1]![1]; + api.session.mockResolvedValue(mina); + authorizeSession(mina, { view: true }, true); + renderApp(root); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + act(() => button("New task").click()); + api.tasks.mockRejectedValue(new Error("List refresh failed")); + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + }); + expect(button("Create task").disabled).toBe(true); + act(() => { + document + .querySelector(".modal-panel form")! + .dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + }); + expect(api.create).not.toHaveBeenCalled(); + }); + + it("ignores browser evaluation from an older list refresh", async () => { + vi.useFakeTimers(); + const mina = tutorialSessions[1]![1]; + api.session.mockResolvedValue(mina); + authorizeSession(mina, { view: true }, true); + renderApp(root); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + const older = envelope(mina, { view: true }, true); + let finishOlder!: (result: { + ceiling: AuthorizationEnvelope["ceiling"]; + stale: boolean; + }) => void; + browserAuthorization.authorizePresentation.mockReturnValueOnce( + new Promise((resolve) => { + finishOlder = resolve; + }), + ); + api.tasks.mockResolvedValueOnce({ tasks: [task], authorization: older }); + api.create.mockResolvedValueOnce({ task }); + act(() => button("New task").click()); + await act(async () => { + document + .querySelector(".modal-panel form")! + .dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + await vi.advanceTimersByTimeAsync(0); + }); + api.tasks.mockResolvedValueOnce({ + tasks: [], + authorization: envelope(mina, {}, false), + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + }); + await act(async () => { + finishOlder({ ceiling: older.ceiling, stale: false }); + }); + expect(document.body.textContent).not.toContain("New task"); + expect(document.querySelector(".task-ledger")?.textContent).not.toContain( + task.title, + ); + }); + + it("does not carry a pending list response into a new session", async () => { + const mina = tutorialSessions[1]![1]; + api.session.mockResolvedValue(mina); + const older = { + tasks: [task], + authorization: envelope(mina, { view: true }, true), + }; + let finishOlder!: (result: typeof older) => void; + api.tasks.mockReturnValueOnce( + new Promise((resolve) => { + finishOlder = resolve; + }), + ); + renderApp(root); + await settle(); + act(() => root.render(null)); + api.session.mockResolvedValue(alex); + authorizeSession(alex, { view: true }, false); + renderApp(root); + await settle(); + await act(async () => { + finishOlder(older); + }); + expect(document.body.textContent).not.toContain("New task"); + expect(document.querySelector(".identity-copy")?.textContent).toContain( + "Alex Morgan", + ); + }); + it("preserves a failed create form and requires confirmation before delete", async () => { api.session.mockResolvedValue(tutorialSessions[1]?.[1]); authorizeSession( diff --git a/p2-tenantrag/README.md b/p2-tenantrag/README.md index 8031d01..cfc106a 100644 --- a/p2-tenantrag/README.md +++ b/p2-tenantrag/README.md @@ -10,6 +10,9 @@ for the model context. Follow the [tutorial](docs/tutorials.md) to add these checks to the [starting service](https://github.com/GluuFederation/cedarling-tutorials/tree/21b0832be4b31271320df992d04e9d97667d0e38/p2-tenantrag). +The tutorial uses a [step helper](../shared/tools/step/README.md) +to copy the required files from a pinned commit. + ## Architecture ```mermaid @@ -35,23 +38,29 @@ flowchart TD ## Run -Set `P2_VOYAGE_API_KEY` and `P2_OPENROUTER_API_KEY` in `.env` before preparing the corpus or starting Docker. +Work from `p2-tenantrag/`. Create a local `.env` with `P2_VOYAGE_API_KEY` and +`P2_OPENROUTER_API_KEY` before preparing the corpus or starting Docker. `P2_OPENROUTER_MODEL` defaults to `openrouter/free`. To select another free model, set its OpenRouter model ID in `.env`. A model that may incur charges also requires `P2_OPENROUTER_ALLOW_PAID=true`; otherwise requests retain a zero-price routing cap. The Voyage embedding model stays fixed, so changing the OpenRouter answer model does not require rebuilding the corpus. -Setup sends synthetic PDF chunks to Voyage for embedding and makes a small +Native setup sends synthetic PDF chunks to Voyage for embedding and makes a small OpenRouter generation check. During retrieval, Voyage receives your query; OpenRouter receives the query and up to three authorized chunks. Provider calls consume account quota. -Start the application and its own IdP: +Install the host sign-in client dependencies, then start the Docker services: ```bash +pnpm install --frozen-lockfile docker compose up --build ``` +On its first start, Docker builds the missing corpus through Voyage, consuming +quota. Later starts reuse the saved index. It does not run the native setup's +OpenRouter generation check. + API: . The issuer is . Stop the stack with `Ctrl+C`, then `docker compose down`. For native development, run from this project directory: @@ -65,7 +74,7 @@ pnpm dev Run setup once to prepare the corpus; repeat it only to rebuild the corpus. `pnpm dev` refreshes local configuration and policies, starts this -project's IdP, and watches the API. Startup itself makes no AI-provider calls; +project's IdP, and watches the API. Native startup makes no AI-provider calls; retrieval requests do. A missing corpus or provider key stops startup with an error. After updating the PDFs, rebuild the index with `pnpm corpus:reset`, then restart. @@ -93,8 +102,10 @@ Use these accounts to compare access to the same support documents: - Leo (`leo`) is a partner reviewer limited to Tenant A public evidence. - Mallory (`mallory`) is a Tenant B analyst with no Tenant A access. -Run `pnpm auth ada`, complete sign-in, and copy the printed access token into -your HTTP client's bearer-token field. Send this request (replace ``): +Run `pnpm auth ada` and complete sign-in. Use the prefilled username (or enter +`ada`) and any non-empty password, such as `cedarling-is-awesome`, for this local +tutorial IdP. Copy the printed access token into your HTTP client's bearer-token +field. Send this request (replace ``): ```http POST http://localhost:17002/v1/retrievals diff --git a/p2-tenantrag/docs/tutorials.md b/p2-tenantrag/docs/tutorials.md index b46314f..b42fec3 100644 --- a/p2-tenantrag/docs/tutorials.md +++ b/p2-tenantrag/docs/tutorials.md @@ -1,11 +1,12 @@ --- slug: prevent-cross-tenant-rag-data-leaks-with-cedarling title: Prevent Cross-Tenant RAG Leaks with Cedarling -summary: Authorize corpus search and each candidate document before protected text reaches an AI model. +summary: Use Token-Based Access Control (TBAC) to check corpus and document access before a RAG API generates an answer. order: 30 socialImage: ./assets/social-card.webp -socialImageAlt: A Fastify RAG service checks corpus and documents with Cedarling before sending text to an AI model. -lastVerified: 2026-10-07T19:56:00Z +socialImageAlt: A Fastify RAG service checks corpus and documents with Cedarling before sending retrieved evidence to the answer model. +lastVerified: 2026-10-09T10:23:54Z +lastUpdated: 2026-10-09T17:29:36Z --- # Prevent Cross-Tenant RAG Leaks with Cedarling @@ -21,14 +22,15 @@ documents reserved for Ada. We'll close both gaps with Cedarling: check the caller's access to the selected document collection (corpus) before embedding or searching. Then we'll check each -document's tenant, corpus, and permitted readers before loading text. We'll follow Mallory's -request through that change and compare Ada's and Leo's results. By the end, -denied text will stay out of model input and citations, while permitted -documents remain available. The model must never decide access. +document's tenant, corpus, and permitted readers before selecting its text for +the answer. We'll follow Mallory's request through that change and compare Ada's +and Leo's results. Denied text will stay out of the answer model's input and +citations for that caller, while permitted documents remain available. +The model must never decide access. -## Build the integration or try the finished app +## Build the retrieval checks or try the finished API -- To build the integration, start with [Run the starting application](#run-the-starting-application), then add policies and retrieval checks. +- To build the integration, use native Node.js and start with [Run the starting application](#run-the-starting-application). We'll add policies, retrieval checks, and tests to that checkout. - To try the finished app, run the [finished project on main](https://github.com/GluuFederation/cedarling-tutorials/tree/main/p2-tenantrag) using its README, then go to [Check which documents each user can retrieve](#check-which-documents-each-user-can-retrieve). This version already uses Cedarling. If you're building from the starting project, open each **Required step** section @@ -36,7 +38,7 @@ and complete its instructions before continuing. These sections contain the file and changes we'll need.
-What you'll need +Before you start - Git, Node.js 24.21+ within 24.x, and pnpm 10.17.1 for the coding steps. - Docker with Compose is optional for the baseline or finished example; the intermediate coding steps use native Node.js. @@ -47,11 +49,8 @@ and changes we'll need.
-At each copying step, open the linked file on GitHub, choose **Raw**, and copy -its full contents into the stated destination in your baseline checkout. The -short examples explain the parts we'll focus on. Create missing parent -directories first. Paths and commands are relative to -`p2-tenantrag/`; repository-level `shared/` files go one directory above it. +Run the commands from `p2-tenantrag/`. Paths under `shared/` are relative to +the repository root. ## Meet the service and its users @@ -61,7 +60,8 @@ _Ada can read a confidential document, Leo can use public Tenant A documents, an P2 is a Node.js API that searches fictional PDFs and generates answers. Voyage turns text into numeric vectors called embeddings. Orama searches those -vectors, and OpenRouter provides the answer model. There is no browser application. +vectors, and OpenRouter provides the answer model. We'll send requests with +Postman or curl, using an access token from the tutorial's sign-in flow. - **Ada** belongs to Tenant A and may read its public documents and the confidential document explicitly shared with her.[^1] @@ -73,37 +73,40 @@ The bundled Node.js `oidc-provider` handles sign-in and issues access tokens. The API verifies a token, then looks up the caller's tenant in its repository; the document metadata lists who may read confidential content. +We'll use [Token-Based Access Control (TBAC)](https://docs.jans.io/stable/cedarling/#proof-based-authorization-token-based-access-control-tbac): +Cedarling validates the caller's access token from the trusted IdP and evaluates +its claims alongside the tenant and document facts supplied by the API. + Cedarling is the policy decision point (PDP). The retrieval service enforces its decisions as the policy enforcement point (PEP). It checks both corpus and document access with the same embedded Cedarling instance. ## Reproduce the leak before adding Cedarling -Let's try Mallory's cross-tenant question before adding those checks. We'll -inspect the citations as well as the answer, because model wording alone -cannot tell us which documents the service used. +Let's ask about Aster's support-search interruption as Mallory, who belongs to +another tenant. We'll inspect the citations as well as the answer to see which +documents the service supplied to generation. ### Run the starting application -Use a separate checkout so the exercise does not change your existing data: +Use a separate checkout for P2, including if you've already followed another +project. This keeps shared files and exercise data separate: ```bash git clone https://github.com/GluuFederation/cedarling-tutorials.git cedarling-p2 cd cedarling-p2 git switch --detach 21b0832be4b31271320df992d04e9d97667d0e38 cd p2-tenantrag + +# Prepare the tutorial steps. +git restore --source=850e38fd5dc4758848e3fcb05af40f3a9c68760c --worktree -- ../shared/tools/step +node ../shared/tools/step/run.mjs p2 init --source 850e38fd5dc4758848e3fcb05af40f3a9c68760c ``` Put `P2_VOYAGE_API_KEY` and `P2_OPENROUTER_API_KEY` in the ignored project `.env`. Keep their values out of source control and recordings. -For Docker, start the API and its IdP together: - -```bash -docker compose up --build -``` - -For native baseline startup, use two terminals. In the first: +For the coding path, start Node.js in two terminals.[^4] In the first: ```bash pnpm --dir ../shared/identity-provider install --frozen-lockfile @@ -113,20 +116,42 @@ pnpm run setup node --env-file=.local/idp/.env ../shared/identity-provider/dist/main.js ``` -In the second, from `p2-tenantrag/`, run `pnpm dev`. Use one startup method at a -time. The API is `http://localhost:17002`; the IdP is `http://localhost:18002`. -With Docker, also install the project's host dependencies for the authentication -CLI using `pnpm install --frozen-lockfile`. +In the second, from `p2-tenantrag/`, run `pnpm dev`. -`pnpm run setup` builds the Orama corpus index from the fictional PDFs as part -of native setup; the first Docker startup builds it automatically. No separate -`corpus:reset` is needed for a fresh checkout. Preparing the corpus sends PDF -chunks to Voyage, and native setup also checks generation. Retrieval calls +
+Optional: Run the starting app with Docker + +Instead of the native commands above, install the host dependencies for the +authentication CLI, then start the API and its IdP together: + +```bash +pnpm install --frozen-lockfile +docker compose up --build +``` + +The first Docker startup builds its corpus index using Voyage; later starts +reuse it. Its index and IdP configuration live in Docker volumes, separate from +native execution. Both methods use the same ports; run only one at a time. + +
+ +The API is `http://localhost:17002`; the IdP is `http://localhost:18002`. + +`pnpm run setup` builds the Orama corpus index from the fictional PDFs for native +execution. No separate `corpus:reset` is needed for a fresh checkout. Preparing +the corpus sends PDF chunks to Voyage, and native setup also checks generation. Retrieval calls consume provider quota. Use fictional questions: the embedding provider receives your question, and generation receives the question and selected evidence. The baseline does not yet filter that evidence by the caller's permissions. +If setup fails only during its OpenRouter generation check, it has already saved +the index in `data/orama-index.json`. Keep that file, check the provider error, +and continue with the IdP and API startup commands above. The API validates the +index before serving requests. If it reports a missing or stale index, resolve +that indexing problem separately. Repeating setup to retry generation would +spend Voyage quota again. + ### Ask for another tenant's evidence In a free terminal, authenticate as Mallory: @@ -135,7 +160,8 @@ In a free terminal, authenticate as Mallory: pnpm auth mallory ``` -Open the displayed verification URL. The development IdP usually prefills +Open the displayed verification URL, check that its device code matches the +terminal, and choose **Continue**. The development IdP usually prefills `mallory`; enter it if the field is empty. Use any non-empty password, such as `cedarling-is-awesome`, then approve access. Copy the printed token into your HTTP client's bearer-token field. We'll use [Postman](https://learning.postman.com/docs/use/send-requests/create-requests/request-basics); @@ -148,7 +174,7 @@ Content-Type: application/json { "corpusId": "tenant-a-support", - "query": "How should customer retrieval data be isolated?", + "query": "What customer steps and internal corrective actions followed Aster's 12 September support-search interruption?", "limit": 3 } ``` @@ -158,10 +184,19 @@ Tenant A or read its documents. It can load their text and send it to the model for her. Inspect the returned citations as well as the answer. Capture the request and any Tenant A citations. A provider error proves neither -a leak nor a denial; retry a failed generation request. Don't rebuild the corpus -to retry a response: rebuilding consumes Voyage quota. Stop the baseline before -editing. For Docker, use `Ctrl+C`, then `docker compose down` without removing -its volume, and install the native dependencies above for the coding steps. +a leak nor a denial. Retry the same request after a temporary provider failure; +for persistent errors, see [the troubleshooting notes](#check-which-documents-each-user-can-retrieve). +Stop the baseline before editing. + +
+If using Docker: Switch to native development + +Stop the stack with `Ctrl+C`, then run `docker compose down` to keep its volumes. +Follow the native setup commands above for the coding steps. Native execution +needs its own corpus preparation, which consumes Voyage quota. Sign in again +after switching. + +
## Give the server the document permissions it needs @@ -177,7 +212,11 @@ We'll first make the document's reader permissions available to retrieval.
Required step: Update document metadata and index preparation -Replace these existing files with their complete linked contents: +```bash +node ../shared/tools/step/run.mjs p2 metadata +``` + +Updated files: - [`src/rag/fixtures.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p2-tenantrag/src/rag/fixtures.ts) assigns users to tenants and adds confidential readers to documents. - [`src/rag/types.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p2-tenantrag/src/rag/types.ts) defines the permission metadata types. @@ -188,18 +227,36 @@ Replace these existing files with their complete linked contents:
-These files add reader permissions to the index without changing the fictional -PDF content. They map Ada and Leo to Tenant A and Mallory to Tenant B. Only Ada +These files add reader permissions to the document metadata without changing +the fictional PDF content. They map Ada and Leo to Tenant A and Mallory to Tenant B. Only Ada may read the confidential Aster document: ```ts -// src/rag/fixtures.ts (confidential Aster document) -confidentialReaderSubjects: ["ada"], +// src/rag/fixtures.ts +export const fixtureDefinitions: readonly FixtureDefinition[] = [ + // ... other document entries omitted. + { + documentId: "a-confidential", + pdfFileName: "a-confidential.pdf", + title: "Aster Search Interruption Review", + corpusId: "tenant-a-support", + tenantId: "tenant-a", + classification: "confidential", + confidentialReaderSubjects: ["ada"], + // ... page count, chunk count, and file hash omitted. + }, + // ... remaining document entries omitted. +]; ``` -That permission comes from the server's sample data, never the HTTP request. -It is available before loading document text. We'll rebuild the index once -the integration files are in place so the new metadata reaches retrieval. +That permission comes from the server's sample data. At startup, PDF.js extracts +the PDFs and the repository holds their text and metadata in memory. During a +request, the document decision controls which chunks can leave that repository +for generation and citations. + +The vector index stores chunk IDs, document IDs, corpus IDs, and embeddings. +Its digest binds those records to the source text and permission metadata, so +we'll rebuild it after adding the integration files. ## Decide which evidence each caller may use @@ -224,7 +281,11 @@ policy-store/
Required step: Create the four policy-store files -Create these files and copy their complete linked contents: +```bash +node ../shared/tools/step/run.mjs p2 policy-store +``` + +New files: - [`policy-store/metadata.json`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p2-tenantrag/policy-store/metadata.json) identifies the store and version. - [`policy-store/schema.cedarschema`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p2-tenantrag/policy-store/schema.cedarschema) defines token, corpus, document, and context types. @@ -233,10 +294,8 @@ Create these files and copy their complete linked contents:
-The metadata identifies this store and version `1.0.0`. The schema defines the -request types. Both rules live in one policy file with distinct `@id` -annotations. Current document facts arrive in requests, so we need no default -entities or templates. +The two rules have distinct `@id` annotations so we can identify them in +decision logs. Their requests need the following facts: | Design question | P2 answer | | -------------------------------------- | ----------------------------------------------------------------------------------------------- | @@ -249,9 +308,12 @@ entities or templates. | What permits access? | Matching token identity and scope, tenant/corpus match, and document permission | | What must deny? | Another tenant, missing scope, wrong token identity, or confidential content without permission | +The API supplies application facts in `context`; Cedarling adds the validated +token claims under `context.tokens`. + The schema includes the token and issuer types Cedarling builds during JWT -processing. `RAG::Any` satisfies the action's principal-type declaration; it is -not another application user. Multi-issuer requests carry identity in `tokens`. +processing. `RAG::Any` is an empty type for the action's principal declaration; +requests through `authorizeMultiIssuer()` carry identity in `tokens`. The copied `trusted-issuers/tutorial-idp.json` sets `openid_configuration_endpoint` to `http://localhost:18002/.well-known/openid-configuration`. The trusted @@ -262,8 +324,9 @@ The copied `trusted-issuers/tutorial-idp.json` sets `openid_configuration_endpoi ### Check the tenant and document's readers Let's follow the document rule, where Ada's and Leo's permissions differ. -This policy from `policies/server-access.cedar` checks verified token -claims against current application facts: +It first checks token identity, audience, and scope, then the selected corpus +and tenant. Its final condition permits public documents or confidential ones +shared with the caller: ```cedar // policy-store/policies/server-access.cedar @@ -298,10 +361,9 @@ the tenant matches both users, but only Ada's subject appears in `confidential_reader_subjects`. Leo fails that last condition. Mallory's Tenant B account fails the tenant comparison even with a valid token. -Cedarling generates the context key `p2tenantrag_access_token`. Dynamic `sub` -and `aud` claims use tags; `scope` is a list of names separated by spaces. The rule looks -for the complete scope name `document.retrieve`, so a different name such as -`document.retrieve.extra` does not grant retrieval. +Cedarling generates the context key `p2tenantrag_access_token`. The `sub` +and `aud` claims use tags. The `scope` claim contains space-separated permissions; +the rule matches `document.retrieve` as a whole entry anywhere in that list. The other policy, `server-search-tenant-corpus`, requires `corpus.search` with the same token checks and matching corpus/tenant. It does not grant access to every @@ -315,19 +377,21 @@ handled as failures. | `corpus.search` | Caller access token | [`SearchCorpus`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p2-tenantrag/policy-store/policies/server-access.cedar#L2 "server-search-tenant-corpus") | Resolved corpus | Current user, selected corpus, server boundary | Query embedding and vector search | | `document.retrieve` | Same token | [`RetrieveDocument`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p2-tenantrag/policy-store/policies/server-access.cedar#L25 "server-retrieve-authorized-document") | Each unique candidate document | Same trusted context | Load text for generation and citations | -The route chooses actions; the repository supplies tenants, classification, and -reader permissions. Neither the question nor model output supplies trusted facts. +The authorization module chooses actions; the repository supplies tenants, +classification, and reader permissions. Neither the question nor model output supplies trusted facts. Look up search results by document ID in the repository before building requests. ## Put Cedarling in the retrieval path -We have the rules. Next we'll load Cedarling and make retrieval wait for its -decisions in this order: +The corpus check must finish before any query embedding, and the document +checks must finish before text enters the model request. This diagram follows +both decisions through the API, including what happens when one document is +denied or authorization fails: ```mermaid flowchart TD - accTitle: Two checks before evidence reaches the model - accDescr: The API asks embedded Cedarling about the corpus before embedding or searching. It then checks each candidate document before loading text. Denied documents are filtered, and authorization failures stop retrieval. + accTitle: Two checks before evidence reaches the answer model + accDescr: The API asks embedded Cedarling about the corpus before embedding or searching. It then checks each candidate document before selecting its text for this answer. Denied documents are filtered, and authorization failures stop retrieval. Request["API: token and corpus facts"] --> Corpus{"Cedarling: corpus check"} Corpus -->|"DENY or failure"| Stop["Stop retrieval"] Corpus -->|"ALLOW"| Search["Embed question; search Orama"] @@ -344,7 +408,7 @@ Install the pinned dependencies from the project directory: ```bash pnpm add --save-exact @janssenproject/cedarling_wasm@0.0.468 fflate@0.8.3 -pnpm add --save-dev --save-exact @cedar-policy/cedar-wasm@4.12.0 +pnpm add --save-dev --save-exact @cedar-policy/cedar-wasm@4.12.0 @types/node@24.19.0 ``` We'll use a shared builder to validate the policy files and package them for Cedarling. @@ -352,8 +416,11 @@ We'll use a shared builder to validate the policy files and package them for Ced
Required step: Create the shared archive builder -Create these files in the repository-level `shared/` directory and copy their -complete linked contents: +```bash +node ../shared/tools/step/run.mjs p2 archive-builder +``` + +The shared builder files are: - [`shared/policy-store.mjs`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/shared/policy-store.mjs) validates and packages the policy store. - [`shared/policy-store.d.mts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/shared/policy-store.d.mts) provides its TypeScript declarations. @@ -371,47 +438,69 @@ Docker builds an archive from the same source files. The complete `src/authorization.ts` in the next step initializes one [Cedarling](https://www.npmjs.com/package/@janssenproject/cedarling_wasm/v/0.0.468) -instance. Here, `policyStorePath` comes from server configuration: +instance in `createRetrievalAuthorization()`. Startup passes the configured +`policyStorePath` to this function: ```ts // src/authorization.ts import { readFile } from "node:fs/promises"; import { initFromArchiveBytes } from "@janssenproject/cedarling_wasm"; -const archive = new Uint8Array(await readFile(policyStorePath)); -const cedarling = await initFromArchiveBytes( - { - CEDARLING_APPLICATION_NAME: "P2 TenantRAG server", - CEDARLING_LOG_TYPE: "memory", - CEDARLING_LOG_TTL: 300, - CEDARLING_JWT_SIG_VALIDATION: "enabled", - CEDARLING_JWT_SIGNATURE_ALGORITHMS_SUPPORTED: ["RS256"], - CEDARLING_STRICT_SCHEMA_VALIDATION: "enabled", - CEDARLING_TRUSTED_ISSUER_LOADER_TYPE: "SYNC", - }, - archive, -); -if (cedarling.loadedTrustedIssuersCount() < 1) { - await cedarling.shutDown(); - throw new Error("P2 trusted issuer did not load"); +// ... other imports, types, and helpers omitted. + +export async function createRetrievalAuthorization( + policyStorePath: string, +): Promise { + const archive = new Uint8Array(await readFile(policyStorePath)); + // ... log the policy version and archive digest. + const cedarling = await initFromArchiveBytes( + { + CEDARLING_APPLICATION_NAME: "P2 TenantRAG server", + CEDARLING_LOG_TYPE: "memory", + CEDARLING_LOG_TTL: 300, + CEDARLING_JWT_SIG_VALIDATION: "enabled", + CEDARLING_JWT_SIGNATURE_ALGORITHMS_SUPPORTED: ["RS256"], + CEDARLING_STRICT_SCHEMA_VALIDATION: "enabled", + CEDARLING_TRUSTED_ISSUER_LOADER_TYPE: "SYNC", + }, + archive, + ); + if (cedarling.loadedTrustedIssuersCount() < 1) { + await cedarling.shutDown(); + throw new Error("P2 requires at least one trusted issuer"); + } + // ... return authorizeCorpus(), authorizeDocuments(), and close(). } ``` -The IdP must be running before initialization. The complete module logs the -archive version and SHA-256 at startup and calls `shutDown()` on application -shutdown. `src/runtime.ts` supplies these authorization functions to retrieval. +`policyStorePath` resolves to this project's `.local/policy-store.cjar`. +Start the IdP before the API so Cedarling can fetch its configuration and signing +keys from the issuer named in `trusted-issuers/tutorial-idp.json`. With `SYNC` +loading, initialization waits for the configured issuer. The count check also +rejects an archive with no issuer definitions, which Cedarling can otherwise +initialize. + +The module logs the archive version and SHA-256 at startup. `src/runtime.ts` +supplies its authorization functions to retrieval and registers `shutDown()` +through the application's close hook. The entry point waits for cleanup and +flushes output before exiting; a four-second deadline bounds stalled cleanup. ### Check the corpus before searching -Let's connect the initialized instance to retrieval. Copy these files together; -then we'll trace the corpus check and the document batch through them. +Let's connect the initialized instance to retrieval, then trace the corpus check +and document batch through the service.
Required step: Add authorization and update the retrieval service -Create [`src/authorization.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p2-tenantrag/src/authorization.ts) -and copy its full contents to load Cedarling, build requests, and collect decisions. -Replace these existing files with their complete linked contents: +```bash +node ../shared/tools/step/run.mjs p2 server +``` + +The new [`src/authorization.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p2-tenantrag/src/authorization.ts) +loads Cedarling, builds requests, and collects decisions. + +Updated files: - [`src/runtime.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p2-tenantrag/src/runtime.ts) connects authorization to retrieval and shutdown. - [`src/rag/retrieval.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p2-tenantrag/src/rag/retrieval.ts) enforces corpus and document decisions before loading text. @@ -425,43 +514,50 @@ Replace these existing files with their complete linked contents:
-Inside the corpus authorization function, `principal` is the authenticated -caller, `profile` holds its stored permissions, and `corpus` is the stored record. -This expanded request shows what the completed `tokenSet()` and -`requestContext()` helpers provide: +`createRetrievalAuthorization()` returns an object with an `authorizeCorpus()` +method. Its `principal` is the authenticated caller, `profile` holds stored +permissions, and `corpus` is the repository record. Here is that method with +`tokenSet()` and `requestContext()` expanded so we can see the request fields: ```ts // src/authorization.ts -const tokens = [ - { mapping: "P2TenantRAG::Access_token", payload: principal.accessToken }, -]; -const context = { - boundary: "server", - user: { subject: principal.id, tenant_id: profile.tenantId }, - selected_corpus_id: corpus.corpusId, +// Returned object inside createRetrievalAuthorization(): +return { + async authorizeCorpus(requestId, principal, profile, corpus) { + const tokens = [ + { mapping: "P2TenantRAG::Access_token", payload: principal.accessToken }, + ]; + const context = { + boundary: "server", + user: { subject: principal.id, tenant_id: profile.tenantId }, + selected_corpus_id: corpus.corpusId, + }; + const result = await cedarling.authorizeMultiIssuer( + JSON.stringify({ + tokens, + action: 'RAG::Action::"SearchCorpus"', + resource: { + cedar_entity_mapping: { + entity_type: "RAG::Corpus", + id: corpus.corpusId, + }, + corpus_id: corpus.corpusId, + tenant_id: corpus.tenantId, + }, + context, + }), + ); + // ... log the application request ID alongside result.request_id. + for (const log of cedarling.getLogsByRequestId(result.request_id)) { + console.info(JSON.stringify(log, null, 2)); + } + if (result.response.diagnostics.errors.length > 0) { + throw new Error("Cedarling returned policy evaluation errors"); + } + return result.decision; + }, + // ... authorizeDocuments() and close() omitted. }; -const result = await cedarling.authorizeMultiIssuer( - JSON.stringify({ - tokens, - action: 'RAG::Action::"SearchCorpus"', - resource: { - cedar_entity_mapping: { - entity_type: "RAG::Corpus", - id: corpus.corpusId, - }, - corpus_id: corpus.corpusId, - tenant_id: corpus.tenantId, - }, - context, - }), -); -for (const log of cedarling.getLogsByRequestId(result.request_id)) { - console.info(JSON.stringify(log, null, 2)); -} -if (result.response.diagnostics.errors.length > 0) { - throw new Error("Cedarling returned policy evaluation errors"); -} -return result.decision; ``` `authorizeMultiIssuer()` receives a JSON string, so `JSON.stringify()` turns @@ -469,10 +565,12 @@ the request object into the format expected by the Cedarling JavaScript API.[^2] The later `JSON.stringify(log, null, 2)` only formats nested log fields for this local exercise. -In `src/rag/retrieval.ts`, the corpus check runs before query embedding: +In `src/rag/retrieval.ts`, `createRetrievalService()` returns a `retrieve()` +method. After looking up the corpus and caller profile, its `try` block checks +the corpus before embedding the question: ```ts -// src/rag/retrieval.ts +// src/rag/retrieval.ts, inside createRetrievalService() > retrieve() > try if ( !(await dependencies.authorization.authorizeCorpus( requestId, @@ -497,42 +595,52 @@ Neither case reaches `voyage.embed()` or the following `corpusSearch.search()`. ### Check each document before loading its text An allowed corpus search can still return a document the caller may not read. -The retrieval service removes duplicate documents from the search results. Its document check -reuses the token set and context, submitting one item per document. In -`src/authorization.ts`, the expanded batch request looks like this: +The retrieval service removes duplicate documents from the search results. +It then calls `authorizeDocuments()` on the object returned by +`createRetrievalAuthorization()`. That method submits one item per document: ```ts // src/authorization.ts -const batch = await cedarling.authorizeMultiIssuerBatch( - JSON.stringify({ - tokens, - items: documents.map((document) => ({ - action: 'RAG::Action::"RetrieveDocument"', - resource: { - cedar_entity_mapping: { - entity_type: "RAG::Document", - id: document.documentId, - }, - corpus_id: document.corpusId, - tenant_id: document.tenantId, - classification: document.classification, - confidential_reader_subjects: document.confidentialReaderSubjects, - }, - context, - })), - }), -); +// Returned object inside createRetrievalAuthorization(): +return { + // ... authorizeCorpus() omitted. + async authorizeDocuments(requestId, principal, profile, corpus, documents) { + if (documents.length === 0) return []; + const batch = await cedarling.authorizeMultiIssuerBatch( + JSON.stringify({ + tokens: tokenSet(principal), + items: documents.map((document) => ({ + action: 'RAG::Action::"RetrieveDocument"', + resource: { + cedar_entity_mapping: { + entity_type: "RAG::Document", + id: document.documentId, + }, + corpus_id: document.corpusId, + tenant_id: document.tenantId, + classification: document.classification, + confidential_reader_subjects: document.confidentialReaderSubjects, + }, + context: requestContext(principal, profile, corpus), + })), + }), + ); + // ... validate each result, log its decision, and return decisions in order. + }, + // ... close() omitted. +}; ``` The module checks `item.is_ok` before `item.unwrap()`, rejects diagnostic errors, and collects decisions in submitted order. It prints Cedarling's logs using each result's request ID. A successfully evaluated item can still contain DENY. -The service checks that every document received a decision, then loads text only -from the allowed set: +Back in `retrieve()`, the service checks that every document received a decision, +then loads text only from the allowed set: ```ts -// src/rag/retrieval.ts +// src/rag/retrieval.ts, inside createRetrievalService() > retrieve() > try +// ... search, resolve uniqueDocuments, and obtain their decisions above. if (decisions.length !== uniqueDocuments.length) throw new Error("Cedarling returned an incomplete document batch"); const allowedDocuments = new Set( @@ -550,8 +658,8 @@ const chunks = selected.map((candidate) => ); ``` -Generation and citations use those same chunks. Denied documents cannot reach -either; an incomplete batch stops retrieval before any text loads. +Generation and citations use those same chunks. An incomplete batch stops the +request before it can load any chunks from the repository. Query bounds, the server-selected corpus filter, input validation, and provider timeouts remain in place. Restart after policy edits; metadata or PDF changes @@ -560,47 +668,39 @@ also require rebuilding the index, as in the next step. ## Rebuild the index and restart the app The request path now enforces both decisions. Before we retry Mallory's request, -we need the archive in our startup paths and the reader grants in the index. +we need the archive in our startup paths and an index whose digest matches the +updated document metadata.
Required step: Update index preparation and startup -Create these files and copy their complete linked contents: +```bash +node ../shared/tools/step/run.mjs p2 startup +``` + +New files: - [`scripts/prepare.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p2-tenantrag/scripts/prepare.ts) prepares identity configuration and the policy archive without provider requests. - [`scripts/preflight.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p2-tenantrag/scripts/preflight.ts) checks the index before startup. - [`scripts/dev.mjs`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p2-tenantrag/scripts/dev.mjs) starts the IdP and API together. -Replace these existing files with their complete linked contents: +Updated files: - [`scripts/setup.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p2-tenantrag/scripts/setup.ts) prepares configuration and verifies the live-provider corpus. - [`Dockerfile`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p2-tenantrag/Dockerfile) includes the archive in the API image. - [`compose.yaml`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p2-tenantrag/compose.yaml) starts the configured services with their readiness checks. - [`shared/dev-supervisor.mjs`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/shared/dev-supervisor.mjs), at repository root, manages native processes and readiness checks. -
- -Apply the `build` entry shown below to your existing -[`package.json`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p2-tenantrag/package.json), keeping -the other dependencies and scripts. Also set `dev` to `node scripts/dev.mjs`. - -The build runs the shared archive builder before TypeScript; the copied -Dockerfile includes that archive in the API image: +The step also updates `build` to package policies and `dev` to start the IdP and API together. -```json -{ - "scripts": { - "build": "node ../shared/policy-store.mjs && tsc -p tsconfig.build.json" - } -} -``` +
`scripts/prepare.ts` synchronizes the local IdP configuration and builds policies without using AI quota. `scripts/preflight.ts` refuses startup if the index is missing. The new `scripts/dev.mjs` starts both the IdP and API without rebuilding the index, replacing the baseline's two-terminal native startup. -For this baseline checkout, rebuild the index once to include the reader grants: +For this baseline checkout, rebuild the index once to bind it to the reader grants: ```bash pnpm corpus:reset @@ -610,11 +710,13 @@ pnpm dev `corpus:reset` consumes Voyage quota; the baseline index cannot be reused unchanged. Stop the separate IdP terminal before `pnpm dev`, which now owns both -services. Wait for the API at `http://localhost:17002` before testing. +services. Wait for the API at `http://localhost:17002` before testing. Its +`/openapi.json` endpoint should return the P2 API description. At this point, +the application builds and runs; we'll replace the baseline's permissive test +expectations with integration tests below. In a fresh finished checkout, use `pnpm run setup` instead: it builds the corpus -and sends a small request to check the provider. Repeat setup or `pnpm corpus:reset` only -when the PDF/index needs rebuilding. +and sends a small request to check the provider. ## Check which documents each user can retrieve @@ -632,12 +734,14 @@ pnpm auth mallory Complete sign-in and replace the bearer token in your HTTP client. P2 tokens expire after 30 minutes, so the one from the baseline exercise may now return `401 authentication_required` before Cedarling evaluates the request. +Keep a separate token for each account, and check the username on the IdP's +approval page when switching between them. Repeat Mallory's original request for `tenant-a-support`. Expect **404** with `error: "corpus_not_found"`, before embedding, search, or generation. An unknown corpus has the same status and error code, not a guarantee of identical timing. -Now authenticate as Ada, then Leo, and send: +Now authenticate as Ada, then Leo, and send the same question: ```http POST http://localhost:17002/v1/retrievals @@ -659,31 +763,49 @@ If Cedarling denies Leo access to a confidential document, must the whole request fail? Check where the retrieval code filters documents, then compare the outcomes below. -| Attempt | Expected outcome | -| ---------------------------------------- | ------------------------------------------------- | -| Mallory searches Tenant A | Corpus DENY; 404; no remote retrieval work | -| Ada retrieves candidate `a-confidential` | Document ALLOW | -| Leo retrieves the same candidate | Document DENY; its chunks never load | -| Leo retrieves Tenant A public candidates | ALLOW; generation may use those chunks | -| Leo selects `tenant-b-support` | Corpus DENY, regardless of the question's wording | +| Attempt | Expected outcome | +| ---------------------------------------- | -------------------------------------------------------- | +| Mallory searches Tenant A | Corpus DENY; 404; no embedding or generation | +| Ada retrieves candidate `a-confidential` | Document ALLOW | +| Leo retrieves the same candidate | Document DENY; its chunks are excluded from this request | +| Leo retrieves Tenant A public candidates | ALLOW; generation may use those chunks | +| Leo selects `tenant-b-support` | Corpus DENY, regardless of the question's wording | +| Mallory searches `tenant-b-support` | Corpus ALLOW; only public Tenant B evidence may be used | + +To exercise the cross-tenant denial as Leo, change only `corpusId` in the same +request to `tenant-b-support`: expect **404 `corpus_not_found`**. Then use +Mallory's token with that corpus and ask `What customer steps are in Beacon's +support search guide?` She may retrieve `b-public`; `b-confidential` has no +granted readers. Its text must stay out of the response and model input. A denied document does not deny the entire question. With no allowed chunks, expect **200**, `answer: null`, and `citations: []`, without generation. A **503 `retrieval_unavailable`** is instead a runtime or provider failure. -With the free OpenRouter model router, availability and answer quality vary; -retry a 503 before treating it as an application bug. A short answer such as -`"User Safety: safe"` is not the expected business answer and is not evidence -of authorization. Judge access by Cedarling decisions and citations, not the -model's wording. To try another model, set `P2_OPENROUTER_MODEL` in `.env`. -Paid routing requires both a paid model ID and -`P2_OPENROUTER_ALLOW_PAID=true`; use it only if you intend to spend your -OpenRouter credit. Adding credit alone leaves the configured -`openrouter/free` router unchanged. -Don't rerun `pnpm run setup` to retry generation: it rebuilds the corpus using Voyage quota. + +Free-model answers vary. A response such as `"User Safety: safe"` does not answer +the business question or prove access was checked. Use the Cedarling decisions +and citations to judge access, even when the answer is unhelpful. + +
+Troubleshooting the request + +- **401:** sign in again and replace the bearer token. Confirm which account approved the device request. +- **404:** check the corpus ID and the account's tenant. The response deliberately does not reveal whether another tenant's corpus exists. +- **503 at `query.embedding` or `answer.generate`:** inspect the logged provider reason. Retry timeouts or temporary unavailability; check credentials and provider configuration for persistent errors. At `answer.generate`, an upstream `httpStatus: 200` can still accompany `empty_answer` or an invalid response: the provider returned no usable answer, so P2 reports 503. +- **503 at an authorization stage:** check the policy and Cedarling error evidence before retrying. A provider-model change cannot repair a policy error. +- **Startup reports a stale index:** rebuild once with `pnpm corpus:reset`, then restart. Ordinary answer retries do not need a rebuild. + +To try another answer model, set `P2_OPENROUTER_MODEL` in `.env` and restart the +API. Paid routing requires both a paid model ID and +`P2_OPENROUTER_ALLOW_PAID=true`; enable it only if you intend to spend OpenRouter +credit. Adding credit alone leaves the configured `openrouter/free` router +unchanged. Neither free nor paid routing guarantees a successful or correct answer. + +
### Read the retrieval and decision logs -To explain those differences, find the response's `requestId` in +Read the server terminal (or the application's Compose output). Find the response's `requestId` in `authorization.context`. Its `cedarlingRequestId` matches Cedarling's `request_id`; document decisions also share `batch_id`. For Leo's confidential-document attempt, the main Cedarling log fields @@ -701,8 +823,8 @@ look like this: ``` Identity comes from tokens in these requests, so the principal array -is empty. An empty reason with no errors means no permit matched; it doesn't -list failed conditions. An allowed document names +is empty. For these permit-only policies, an empty reason with no errors means +no permit matched; it doesn't list failed conditions.[^3] An allowed document names `server-retrieve-authorized-document` in its reasons. `retrieval.completed` reports candidates, documents evaluated, chunks loaded, @@ -713,51 +835,76 @@ Capture only fictional data and remove tokens and secrets from recordings. ### Check denied text stays out of the result -We've checked live responses and their decisions. To verify that denied text -never reaches generation, use the automated checks in a separate checkout of the -[finished project on main](https://github.com/GluuFederation/cedarling-tutorials/tree/main/p2-tenantrag), install its locked project and shared IdP dependencies, then run: +Successful responses cite the chunks supplied to the answer model. We'll now +add tests to this same checkout to verify when text is loaded and what happens +if a decision fails. + +
+Required step: Add the matching tests and check configuration + +```bash +node ../shared/tools/step/run.mjs p2 checks +``` + +Updated files: + +- [`test/app.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p2-tenantrag/test/app.test.ts) checks HTTP input, responses, and failure handling. +- [`test/auth-cli.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p2-tenantrag/test/auth-cli.test.ts) checks the sign-in CLI's configuration and output. +- [`test/config.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p2-tenantrag/test/config.test.ts) checks issuer, audience, and archive settings. +- [`test/corpus.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p2-tenantrag/test/corpus.test.ts) checks index integrity against the permission metadata. +- [`test/retrieval.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p2-tenantrag/test/retrieval.test.ts) checks call order, denied-text exclusion, empty results, and failures with controlled dependencies. +- [`test/trace.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p2-tenantrag/test/trace.test.ts) checks retrieval logs and their safe fields. +- [`scripts/test-e2e.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p2-tenantrag/scripts/test-e2e.ts) exercises the three accounts against the running API and live providers. +- [`eslint.config.js`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p2-tenantrag/eslint.config.js) includes the JavaScript development supervisor in linting. + +New files: + +- [`test/policy-store.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p2-tenantrag/test/policy-store.test.ts) evaluates signed requests with real Cedarling and a local test issuer. +- [`test/setup.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p2-tenantrag/test/setup.test.ts) checks local preparation and missing-index errors without provider calls. +- [`test/server-lifecycle.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p2-tenantrag/test/server-lifecycle.test.ts) checks process shutdown with real Cedarling and local fixtures. + +Keep the other baseline tests and the existing `check` script. + +
+ +Stop the app and IdP to free ports 17002 and 18002 for the local lifecycle tests. +From this checkout's `p2-tenantrag/`, run: ```bash +pnpm format pnpm check ``` This runs formatting, lint, types, tests, and build without provider keys. -The checks evaluate real Cedarling policies with signed test evidence. They -verify decision order, removal of denied text, empty results, and failed -or incomplete decisions using fixed provider responses. These are only for -tests; the application still needs live providers. Unsupported content -types also return `400 invalid_retrieval` before retrieval runs. - -In that finished checkout, run setup and start the integrated app before -running `pnpm test:e2e`. +In `test/policy-store.test.ts`, compare `allows Ada both public and explicitly +granted Tenant A evidence` with `allows Leo public evidence but denies +confidential evidence`. The same candidate documents receive different decisions. +The retrieval tests check that denied chunks never reach `loadChunkText()` or +the model, and that an incomplete batch stops the request. + +To repeat the live scenarios automatically, restart this integrated app and its +IdP with `pnpm dev` and run `pnpm test:e2e` from another terminal. Approve the Ada, Leo, and Mallory sign-ins. This consumes provider quota and -checks document access, not identical model answers. Record Mallory's request +can fail when a provider is unavailable; use the troubleshooting steps above. +It checks document access, not identical model answers. Record Mallory's request before and after integration, plus one successful authorized search.
-Warning: Before deploying this application - -Replace local HTTP and the bundled learning IdP with HTTPS and a configured -OIDC/OAuth issuer, such as [Jans Auth](https://docs.jans.io/stable/janssen-server/planning/use-cases/), -Gluu, Auth0, or Okta. +Warning: Learning project only -Use trusted user and permission records, review how providers handle data, and -protect logs. Setup sends the fictional corpus for embedding to build the index; -it does not check a caller's access to those documents. Cedarling controls access -to documents, not answer accuracy or prompt injection. A public document -containing instructions is still allowed content. +This project is for learning only and is not intended for production use. +Adapting it requires a separate review of the local HTTP/IdP setup, +fixture-based permissions, provider data handling, and logging. Cedarling +controls document access; it does not guarantee answer accuracy or prevent +prompt injection. An allowed document can still contain instructions. -For a production stack, consider Agama Lab Policy Designer for policy authoring, -Jans Auth for issuing tokens, and Lock Server for centralized decision logs. -See [Cedarling production solutions](https://cedarling.dev/solutions). - -These steps were prepared on Ubuntu 24.04+. Native project checks also run in CI -on macOS and Windows. If a platform-specific step fails, -[open an issue](https://github.com/GluuFederation/cedarling-tutorials/issues). +Optional components include Jans Auth for identity, Agama Lab Policy Designer +for policy authoring, and Lock Server for decision logs. See +[Cedarling production solutions](https://cedarling.dev/solutions).
-## What we've learned +## Recap: access to retrieved documents Mallory's cross-tenant request now stops before embedding or search. Within Tenant A, Ada can use confidential evidence while Leo receives only documents @@ -776,3 +923,7 @@ operations that let an assistant read information and change incident state. [^1]: P2's confidential-reader rule uses the current document's `confidential_reader_subjects` set. Ada's subject is listed; Leo's is not. The application supplies this relationship for each authorization request. [^2]: The pinned [`cedarling_wasm` JavaScript API](https://www.npmjs.com/package/@janssenproject/cedarling_wasm/v/0.0.468) accepts a JSON-string request. `JSON.stringify()` converts the request to JSON. + +[^3]: Cedar reports determining policies, not a list of conditions that failed. With these permit-only rules, a denied request has no determining policy. A matched `forbid` would instead be reported. See [How Cedar authorization works](https://docs.cedarpolicy.com/auth/authorization.html). + +[^4]: These steps were prepared on Ubuntu 24.04+. Native project checks also run in CI on macOS and Windows. If a platform-specific step fails, [open an issue](https://github.com/GluuFederation/cedarling-tutorials/issues). diff --git a/p2-tenantrag/scripts/dev.mjs b/p2-tenantrag/scripts/dev.mjs index d13fc47..ea99026 100644 --- a/p2-tenantrag/scripts/dev.mjs +++ b/p2-tenantrag/scripts/dev.mjs @@ -1,5 +1,6 @@ /** Supervise the local P2 IdP and API using the explicitly prepared corpus. */ import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; import { resolve } from "node:path"; import { parseEnv } from "node:util"; import { issuerHealth, runDevStack } from "../../shared/dev-supervisor.mjs"; @@ -7,6 +8,7 @@ import { issuerHealth, runDevStack } from "../../shared/dev-supervisor.mjs"; const root = resolve(import.meta.dirname, ".."); const identityRoot = resolve(root, "../shared/identity-provider"); const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; +const tsx = createRequire(import.meta.url).resolve("tsx/cli"); try { await runDevStack({ @@ -40,8 +42,8 @@ try { }, { name: "P2 application", - command: pnpm, - args: ["exec", "tsx", "watch", "src/main.ts"], + command: process.execPath, + args: [tsx, "watch", "src/main.ts"], cwd: root, env, health: { diff --git a/p2-tenantrag/src/authorization.ts b/p2-tenantrag/src/authorization.ts index aa94579..5531804 100644 --- a/p2-tenantrag/src/authorization.ts +++ b/p2-tenantrag/src/authorization.ts @@ -81,7 +81,7 @@ export async function createRetrievalAuthorization( ); if (cedarling.loadedTrustedIssuersCount() < 1) { await cedarling.shutDown(); - throw new Error("P2 trusted issuer did not load"); + throw new Error("P2 requires at least one trusted issuer"); } return { diff --git a/p2-tenantrag/src/main.ts b/p2-tenantrag/src/main.ts index 816bc11..93b72e8 100644 --- a/p2-tenantrag/src/main.ts +++ b/p2-tenantrag/src/main.ts @@ -1,3 +1,4 @@ +import { writeSync } from "node:fs"; import { createApp } from "./app.js"; import { loadConfig } from "./config/project-config.js"; import { loadProjectEnvironment } from "./config/environment.js"; @@ -12,9 +13,42 @@ const app = createApp(runtime); await app.listen({ host: config.host, port: config.port }); console.log(`P2 TenantRAG listening at ${config.baseUrl}`); -for (const signal of ["SIGINT", "SIGTERM"] as const) { - process.once(signal, () => { - console.log(`Received ${signal}; stopping P2 TenantRAG`); - void app.close().finally(() => process.exit(0)); - }); +let stopping = false; + +async function shutDown(signal: string): Promise { + if (stopping) return; + stopping = true; + // Finish before the development supervisor's five-second shutdown limit. + const deadline = setTimeout(() => { + writeSync(2, "P2 shutdown timed out\n"); + process.exit(1); + }, 4_000); + console.log(`Received ${signal}; stopping P2 TenantRAG`); + let exitCode = 0; + try { + await app.close(); + } catch { + console.error("P2 shutdown failed"); + exitCode = 1; + } + try { + await Promise.all( + [process.stdout, process.stderr].map( + (stream) => + new Promise((resolve, reject) => { + stream.write("", (error) => { + if (error) reject(error); + else resolve(); + }); + }), + ), + ); + } catch { + exitCode = 1; + } + clearTimeout(deadline); + // The executable exits after cleanup; reusable authorization code only closes resources. + process.exit(exitCode); } +process.on("SIGINT", () => void shutDown("SIGINT")); +process.on("SIGTERM", () => void shutDown("SIGTERM")); diff --git a/p2-tenantrag/test/policy-store.test.ts b/p2-tenantrag/test/policy-store.test.ts index a011939..33d8aa8 100644 --- a/p2-tenantrag/test/policy-store.test.ts +++ b/p2-tenantrag/test/policy-store.test.ts @@ -217,6 +217,34 @@ afterAll(async () => { }); describe("P2 corpus policy", () => { + test("rejects an archive without trusted issuer definitions", async () => { + const root = await mkdtemp(join(tmpdir(), "cedarling-p2-no-issuers-")); + let instance: RetrievalAuthorization | undefined; + try { + await cp(policyStoreSource, join(root, "policy-store"), { + recursive: true, + filter: (source) => + source !== join(policyStoreSource, "trusted-issuers"), + }); + const archive = await buildPolicyStore({ + projectRoot: root, + dependencyRoot: projectRoot, + }); + const initialized = createRetrievalAuthorization(archive.outputPath).then( + (value) => { + instance = value; + return value; + }, + ); + await expect(initialized).rejects.toThrow( + "P2 requires at least one trusted issuer", + ); + } finally { + await instance?.close(); + await rm(root, { recursive: true, force: true }); + } + }); + test("rejects a token without the required OAuth scope claim", async () => { const caller = { id: "ada", diff --git a/p2-tenantrag/test/server-lifecycle.test.ts b/p2-tenantrag/test/server-lifecycle.test.ts new file mode 100644 index 0000000..0ef4718 --- /dev/null +++ b/p2-tenantrag/test/server-lifecycle.test.ts @@ -0,0 +1,214 @@ +import { spawn } from "node:child_process"; +import { generateKeyPairSync } from "node:crypto"; +import { cp, mkdtemp, rm } from "node:fs/promises"; +import { createServer, type Server } from "node:http"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { afterAll, beforeAll, expect, test } from "vitest"; +import { buildPolicyStore } from "../../shared/policy-store.mjs"; +import { loadAllFixtures } from "../src/rag/pdf.js"; +import { + createCorpusArtifact, + writeCorpusArtifact, +} from "../src/rag/corpus.js"; + +const projectRoot = fileURLToPath(new URL("..", import.meta.url)); +const entry = fileURLToPath(new URL("../src/main.ts", import.meta.url)); +const loader = pathToFileURL( + createRequire(import.meta.url).resolve("tsx"), +).href; +let root: string; +let issuer: Server | undefined; + +beforeAll(async () => { + const jwk = generateKeyPairSync("rsa", { + modulusLength: 2048, + }).publicKey.export({ format: "jwk" }); + issuer = createServer((request, response) => { + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify( + request.url === "/jwks" + ? { + keys: [{ ...jwk, kid: "lifecycle", use: "sig", alg: "RS256" }], + } + : { + issuer: "http://localhost:18002", + jwks_uri: "http://localhost:18002/jwks", + authorization_endpoint: "http://localhost:18002/auth", + token_endpoint: "http://localhost:18002/token", + }, + ), + ); + }); + await new Promise((resolve, reject) => { + issuer!.once("error", reject); + issuer!.listen(18002, resolve); + }); + root = await mkdtemp(join(tmpdir(), "p2-lifecycle-")); + await cp(join(projectRoot, "policy-store"), join(root, "policy-store"), { + recursive: true, + }); + await buildPolicyStore({ projectRoot: root, dependencyRoot: projectRoot }); + await cp(join(projectRoot, "fixtures"), join(root, "fixtures"), { + recursive: true, + }); + const documents = await loadAllFixtures(join(root, "fixtures")); + const artifact = await createCorpusArtifact( + documents, + { embed: async (texts) => texts.map(() => Array(256).fill(0)) }, + "voyage-4-lite", + ); + await writeCorpusArtifact(join(root, "data/orama-index.json"), artifact); +}, 30_000); + +afterAll(async () => { + issuer?.closeAllConnections(); + if (issuer?.listening) + await new Promise((resolve) => { + issuer!.close(() => resolve()); + }); + if (root) await rm(root, { recursive: true, force: true }); +}); + +async function stopApplication(signal: string, mode = "normal") { + // Fastify's public diagnostics channel lets a test plugin delay or fail cleanup. + const script = ` + import { subscribe } from 'node:diagnostics_channel'; + import { pathToFileURL } from 'node:url'; + subscribe('fastify.initialization', ({ fastify }) => { + fastify.addHook('onClose', async () => { + console.log('lifecycle-close-start'); + if (process.env.LIFECYCLE_MODE === 'hang') await new Promise(() => {}); + if (process.env.LIFECYCLE_MODE === 'error') throw new Error('private-cleanup-detail'); + await new Promise(resolve => setTimeout(resolve, 100)); + if (process.env.LIFECYCLE_MODE === 'output') { + process.stdout.write('x'.repeat(256 * 1024) + 'lifecycle-output-finished\\n'); + } + console.log('lifecycle-close-finished'); + }); + }); + await import(pathToFileURL(process.env.LIFECYCLE_ENTRY).href); + console.log('lifecycle-ready'); + process.on('message', () => { + process.emit(process.env.LIFECYCLE_SIGNAL, process.env.LIFECYCLE_SIGNAL); + if (process.env.LIFECYCLE_MODE === 'repeat') { + process.emit('SIGINT', 'SIGINT'); + process.emit('SIGTERM', 'SIGTERM'); + } + process.disconnect(); + }); + `; + const child = spawn( + process.execPath, + ["--import", loader, "--input-type=module", "-e", script], + { + cwd: root, + env: { + ...process.env, + P2_PROJECT_ROOT: root, + P2_HOST: "127.0.0.1", + P2_PORT: "17002", + P2_BASE_URL: "http://localhost:17002", + P2_ISSUER: "http://localhost:18002", + P2_API_RESOURCE: "http://localhost:17002/api", + P2_CLIENT_ID: "p2-tenantrag-cli", + P2_VOYAGE_API_KEY: "lifecycle-test-only", + P2_OPENROUTER_API_KEY: "lifecycle-test-only", + P2_OPENROUTER_MODEL: "openrouter/free", + P2_OPENROUTER_ALLOW_PAID: "false", + LIFECYCLE_ENTRY: entry, + LIFECYCLE_SIGNAL: signal, + LIFECYCLE_MODE: mode, + }, + stdio: ["ignore", "pipe", "pipe", "ipc"], + }, + ); + let output = ""; + let error = ""; + let ready = false; + let timedOut = false; + const startupTimeout = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, 10_000); + let shutdownTimeout: ReturnType | undefined; + child.stdout!.on("data", (chunk: Buffer) => { + output += chunk.toString(); + if (!ready && output.includes("lifecycle-ready")) { + ready = true; + clearTimeout(startupTimeout); + shutdownTimeout = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, 5_000); + // POSIX runs also cover delivery of actual operating-system signals. + if (process.platform !== "win32" && mode === "normal") { + child.kill(signal as NodeJS.Signals); + } else child.send("stop"); + } + }); + child.stderr!.on("data", (chunk: Buffer) => { + error += chunk.toString(); + }); + const code = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }).finally(() => { + clearTimeout(startupTimeout); + clearTimeout(shutdownTimeout); + }); + return { code, output, error, ready, timedOut }; +} + +test.each(["SIGINT", "SIGTERM"])( + "P2 exits after %s with a real Cedarling instance", + async (signal) => { + const result = await stopApplication(signal); + expect(result.ready, result.error).toBe(true); + expect(result.output).toContain("lifecycle-close-finished"); + expect(result.timedOut).toBe(false); + expect(result.code).toBe(0); + }, + 20_000, +); + +test("repeated shutdown signals wait for one cleanup", async () => { + const result = await stopApplication("SIGTERM", "repeat"); + expect(result.ready, result.error).toBe(true); + expect(result.output.match(/lifecycle-close-start/g)).toHaveLength(1); + expect(result.output).toContain("lifecycle-close-finished"); + expect(result.timedOut).toBe(false); + expect(result.code).toBe(0); +}, 20_000); + +test("shutdown flushes pending output before exiting", async () => { + const result = await stopApplication("SIGTERM", "output"); + expect( + result.output.includes( + "x".repeat(256 * 1024) + "lifecycle-output-finished", + ), + ).toBe(true); + expect(result.timedOut).toBe(false); + expect(result.code).toBe(0); +}, 20_000); + +test("cleanup failure exits unsuccessfully without exposing the error", async () => { + const result = await stopApplication("SIGTERM", "error"); + expect(result.ready, result.error).toBe(true); + expect(result.error).toContain("P2 shutdown failed"); + expect(result.error).not.toContain("private-cleanup-detail"); + expect(result.timedOut).toBe(false); + expect(result.code).toBe(1); +}, 20_000); + +test("stalled cleanup exits within the shutdown deadline", async () => { + const result = await stopApplication("SIGTERM", "hang"); + expect(result.ready, result.error).toBe(true); + expect(result.output).not.toContain("lifecycle-close-finished"); + expect(result.error).toContain("P2 shutdown timed out"); + expect(result.timedOut).toBe(false); + expect(result.code).toBe(1); +}, 20_000); diff --git a/p3-mcp-capability-governance/README.md b/p3-mcp-capability-governance/README.md index ee445f5..234e871 100644 --- a/p3-mcp-capability-governance/README.md +++ b/p3-mcp-capability-governance/README.md @@ -9,6 +9,9 @@ an incident. Follow the [tutorial](docs/tutorials.md) to secure the [starting application](https://github.com/GluuFederation/cedarling-tutorials/tree/21b0832be4b31271320df992d04e9d97667d0e38/p3-mcp-capability-governance) with a [private Cedarling sidecar](https://docs.jans.io/stable/cedarling/developer/sidecar/cedarling-sidecar-overview/). +The tutorial uses a [step helper](../shared/tools/step/README.md) +to copy the required files from a pinned commit. + ## Architecture ```mermaid @@ -31,8 +34,9 @@ evidence and current account/incident facts to the sidecar. The model receives neither the token nor control over those facts. Discovery and direct calls each require permission; see the [enforcement walkthrough](docs/tutorials.md#check-permission-before-each-mcp-operation). -Compose builds `policy-store/` into `.local/policy-store.cjar` and mounts it -read-only in the sidecar. Only the MCP and IdP ports are published on host +The Docker build packages `policy-store/` into `.local/policy-store.cjar`. +Compose copies that archive to a named volume, where the sidecar reads +`/policy-store/policy-store.cjar` through a read-only mount. Only the MCP and IdP ports are published on host loopback. These services share a network namespace for local learning. ## Prerequisites @@ -47,7 +51,7 @@ P3 starts its own tutorial IdP at `http://localhost:18003` and MCP service at ## Run -From this project directory, start the services: +From `p3-mcp-capability-governance/`, start the services: ```bash docker compose up --build @@ -74,8 +78,10 @@ and has no paid fallback. Provider availability can vary. The provider may retain prompts and responses for training; use only fictional incident data. -Open the displayed verification URL, sign in as the chosen persona, and approve -access. The MCP endpoint is `http://localhost:17003/mcp`. Leave the service +Open the displayed verification URL and use the prefilled username, or enter +the chosen persona's ID. This local tutorial IdP accepts any non-empty password, +such as `cedarling-is-awesome`. Approve access. +The MCP endpoint is `http://localhost:17003/mcp`. Leave the service terminal open while using chat. ## Exercise @@ -105,6 +111,22 @@ The model must return a valid operation selection before MCP can execute it. Chat reports provider, quota, timeout, and invalid-response failures separately from authorization denials. +To send a request without OpenRouter, use the direct client after host setup: + +```bash +pnpm exec tsx scripts/mcp-request.ts dana search INC-2001 +pnpm exec tsx scripts/mcp-request.ts amir update INC-2001 mitigated resolved +pnpm exec tsx scripts/mcp-request.ts dana search INC-2001 +``` + +Each command asks you to sign in through Device Flow. Confirm Amir's update with +`y`; it returns `authorization_denied` and a nonzero exit status. Dana's two +searches confirm the incident is unchanged. The helper supports `search ` +and `update `, keeps the token in +memory, and creates a fresh idempotency key for each update. Declining sends no +update. [The tutorial](docs/tutorials.md#try-direct-calls-and-a-sidecar-outage) +also covers permitted updates and sidecar failure. + The MCP server prints JSON `authorization.decision` or `authorization.failed` records. The sidecar prints native Cedarling decisions with policy reasons and separate request IDs. The [log guide](docs/tutorials.md#read-the-server-and-sidecar-logs) diff --git a/p3-mcp-capability-governance/docs/tutorials.md b/p3-mcp-capability-governance/docs/tutorials.md index 93d2e94..06832b4 100644 --- a/p3-mcp-capability-governance/docs/tutorials.md +++ b/p3-mcp-capability-governance/docs/tutorials.md @@ -1,11 +1,12 @@ --- slug: govern-mcp-capabilities title: Govern MCP Capabilities with Cedarling -summary: Protect MCP tools, resources, and prompts with signed identity evidence and a private Cedarling sidecar. +summary: Use Token-Based Access Control (TBAC) to authorize MCP tools, resources, and prompts through a private Cedarling sidecar. order: 40 socialImage: ./assets/social-card.webp socialImageAlt: An MCP server checks incident capabilities with a private Cedarling sidecar before protected effects. -lastVerified: 2026-10-07T19:56:00Z +lastVerified: 2026-10-09T11:47:10Z +lastUpdated: 2026-10-09T17:29:36Z --- # Govern MCP Capabilities with Cedarling @@ -25,7 +26,7 @@ or the analyst's current assignment. Eve has no incident permissions and must not reach these operations. We'll retry Amir's update, then check his assigned work still succeeds, including calls without the chat interface. -## Build the integration or try the finished app +## Add MCP permissions or run the finished assistant - To build the integration, start with [Run the starting application](#run-the-starting-application), then add the policies and sidecar calls. - To try the finished app, run the [finished project on main](https://github.com/GluuFederation/cedarling-tutorials/tree/main/p3-mcp-capability-governance) using its README, then go to [Check assigned and unassigned incidents](#check-assigned-and-unassigned-incidents). This version already uses Cedarling. @@ -35,7 +36,7 @@ and complete its instructions before continuing. These sections contain the file and changes we'll need.
-What you'll need +Before you start - Git, Node.js 24.21+ within 24.x, and pnpm 10.17.1. - Docker with Compose is required: the completed PDP runs in a containerized Flask sidecar.[^1] The pinned image is `linux/amd64`; ARM Docker Desktop needs amd64 emulation. @@ -44,13 +45,14 @@ and changes we'll need. - New to Cedarling? [Read the short introduction](https://cedarling.dev/learn/what-is-cedarling) when you need it. - Keep the [Cedar policy syntax](https://docs.cedarpolicy.com/policies/syntax-policy.html) and [Cedar schema syntax](https://docs.cedarpolicy.com/schema/human-readable-schema.html) references handy while editing policies. +These steps were prepared on Ubuntu 24.04+. Native project checks also run in CI +on macOS and Windows. If a platform-specific step fails, +[open an issue](https://github.com/GluuFederation/cedarling-tutorials/issues). +
-At each copying step, open the linked file on GitHub, choose **Raw**, and copy -its full contents into the stated destination in your baseline checkout. The -short examples explain the parts we'll focus on. Create missing parent -directories first. Paths and commands are relative to -`p3-mcp-capability-governance/`; repository-level `shared/` files go one directory above it. +Run the commands from `p3-mcp-capability-governance/`. Paths under `shared/` are relative to +the repository root. ## Meet the incident assistant and its users @@ -71,8 +73,10 @@ unassigned audit incident in the mitigated state. Amir should not resolve the second incident just because the model selected its update tool. The bundled Node.js `oidc-provider` authenticates these users and issues their -access tokens. The MCP server looks up roles in its account map and assignments -in the incident repository; neither comes from the model. +access tokens. Cedarling uses Token-Based Access Control (TBAC): policies evaluate +signed tokens from trusted issuers together with the request's facts.[^6] +In P3, the token identifies the caller. The MCP server looks up roles in its +account map and assignments in the incident repository. The host and model request work. The MCP server controls data access and changes. Cedarling supplies decisions; it does not call tools or update incidents. @@ -84,17 +88,36 @@ confirm an update to the incident that nobody assigned to him. ### Run the starting application -Use a separate checkout with its own sample incidents: +Use a separate checkout for P3, including if you've already followed another +project. This keeps shared files and sample incidents separate: ```bash git clone https://github.com/GluuFederation/cedarling-tutorials.git cedarling-p3 cd cedarling-p3 git switch --detach 21b0832be4b31271320df992d04e9d97667d0e38 cd p3-mcp-capability-governance + +# Prepare the tutorial steps. +git restore --source=850e38fd5dc4758848e3fcb05af40f3a9c68760c --worktree -- ../shared/tools/step +node ../shared/tools/step/run.mjs p3 init --source 850e38fd5dc4758848e3fcb05af40f3a9c68760c pnpm install --frozen-lockfile pnpm run setup ``` +
+Required step: Add the direct MCP exercise + +```bash +node ../shared/tools/step/run.mjs p3 mcp-client +``` + +[`scripts/mcp-request.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/scripts/mcp-request.ts) signs in through the same +Device Flow and calls the MCP server without a model. It supports search and +confirmed updates, keeps tokens in memory, and works before and after integration. +We'll use it to check direct access and to continue if the chat provider fails. + +
+ Start the baseline server and its IdP with Docker Compose: ```bash @@ -123,21 +146,55 @@ Confirm with `y` when asked. The baseline lets Amir find and change this unassigned incident. It validates identity, input, confirmation, and the state transition, but does not enforce the assignment rule. -A free provider may fail before making an MCP call. Retry or choose another -model; a provider failure does not prove authorization works. Answers and tool -choices vary, so check the incident state. Capture Amir's unauthorized update, -then stop the baseline with `docker compose down`. Restarting restores the sample incidents. +Find the incident again to confirm it is `resolved`, version `2`. + +
+If chat is unavailable + +The client defaults to `liquid/lfm-2.5-2.6b:free`. Free providers can return +quota, timeout, or unavailable errors before making an MCP call. Retry later +or set `P3_OPENROUTER_MODEL` in `.env` to another free tool-calling model. +A paid model requires both a paid model ID and `P3_OPENROUTER_ALLOW_PAID=true`; +enable this only if you intend to spend your paid credit. + +A provider failure proves no authorization result. To continue without a +provider key, run these commands separately from another terminal: + +```bash +pnpm exec tsx scripts/mcp-request.ts amir search INC-2001 +pnpm exec tsx scripts/mcp-request.ts amir update INC-2001 mitigated resolved +pnpm exec tsx scripts/mcp-request.ts amir search INC-2001 +``` + +Each command opens the same sign-in flow. Confirm the update with `y`. +If the first search already shows `resolved`, the earlier chat request succeeded; +skip the update. Otherwise, expect `mitigated`, version `1`, before the update +and `resolved`, version `2`, afterward. + +
+ +Model answers and tool choices vary, so use incident state as evidence. +The provider may retain prompts and responses for training; use only fictional +incident data. After capturing Amir's unauthorized update, stop the baseline: + +```bash +docker compose down +``` + +The next startup restores the in-memory sample incidents. Configuration volumes +remain in place. ## Where should we check permission? -The update succeeded, so the missing check belongs on the server that changed -the incident. Open the baseline's +Once you've observed Amir resolve the unassigned incident, follow that change +to the server. Open the baseline's [`src/mcp/server.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/21b0832be4b31271320df992d04e9d97667d0e38/p3-mcp-capability-governance/src/mcp/server.ts) and find the `update_incident_status` handler. After authentication and input validation, it logs an ALLOW without checking permission, then calls the repository: ```ts -// src/mcp/server.ts (starting checkpoint) +// src/mcp/server.ts (baseline), inside update_incident_status's callback > try +// ... validate input, load the incident, and log the baseline ALLOW. const incident = services.incidents.updateStatus({ incidentId, expectedStatus, @@ -163,18 +220,16 @@ operations each role may use and when an incident assignment is required. Each action links to its policy. Requests use the signed caller token; roles and assignments come from the server. -| Capability | Identity | Action | Resource | Trusted context | Protected effect | -| -------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | ----------------------------------- | ------------------------------------------- | -| Discover | Signed caller token | [`Discover`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/policy-store/policies/incident-operations.cedar#L16 "operations-surface") | `Service::"incident-assistant"` | Current caller subject and role | Make MCP operations available to the caller | -| Search | Same token | [`Search`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/policy-store/policies/incident-operations.cedar#L16 "operations-surface") | Same service | Same caller | Begin incident search | -| Read result | Same token | [`Read`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/policy-store/policies/incident-operations.cedar#L26 "incident-assignment") | Each candidate `Incident` | Same caller; assignment on resource | Return a matching incident summary | -| Read runbook | Same token | [`ReadRunbook`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/policy-store/policies/incident-operations.cedar#L16 "operations-surface") | `Runbook::"core"` | Same caller | Return runbook content | -| Prepare triage | Same token | [`Triage`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/policy-store/policies/incident-operations.cedar#L26 "incident-assignment") | Current `Incident` | Same caller; assignment on resource | Return an incident-specific prompt | -| Update status | Same token | [`UpdateStatus`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/policy-store/policies/incident-operations.cedar#L26 "incident-assignment") | Current `Incident` | Same caller; assignment on resource | Commit one valid transition | +| Capability | Action | Resource | Trusted context | Protected effect | +| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | ----------------------------------- | ------------------------------------------- | +| Discover | [`Discover`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/policy-store/policies/incident-operations.cedar#L16 "operations-surface") | `Service::"incident-assistant"` | Current caller subject and role | Make MCP operations available to the caller | +| Search | [`Search`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/policy-store/policies/incident-operations.cedar#L16 "operations-surface") | Same service | Same caller | Begin incident search | +| Read result | [`Read`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/policy-store/policies/incident-operations.cedar#L26 "incident-assignment") | Each candidate `Incident` | Same caller; assignment on resource | Return a matching incident summary | +| Read runbook | [`ReadRunbook`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/policy-store/policies/incident-operations.cedar#L16 "operations-surface") | `Runbook::"core"` | Same caller | Return runbook content | +| Prepare triage | [`Triage`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/policy-store/policies/incident-operations.cedar#L26 "incident-assignment") | Current `Incident` | Same caller; assignment on resource | Return an incident-specific prompt | +| Update status | [`UpdateStatus`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/policy-store/policies/incident-operations.cedar#L26 "incident-assignment") | Current `Incident` | Same caller; assignment on resource | Commit one valid transition | -All types and actions use namespace `P3IncidentAssistant`. The model can suggest -an incident ID; the server loads its assignment and the caller's role. Neither -comes from the model or caller JSON. The app still checks status changes and retries. +All types and actions use namespace `P3IncidentAssistant`. ### Create the policy store @@ -194,7 +249,11 @@ policy-store/
Required step: Create the four policy-store files -Create these files and copy their complete linked contents: +```bash +node ../shared/tools/step/run.mjs p3 policy-store +``` + +New files: - [`policy-store/metadata.json`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/policy-store/metadata.json) identifies the store and version. - [`policy-store/schema.cedarschema`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/policy-store/schema.cedarschema) defines the operations, resources, and token context. @@ -208,10 +267,9 @@ Use the integration's metadata with policy version `1.0.0`. The schema defines `assigned_to` attribute. It also defines `Access_token`, `TrustedIssuer`, the issuer URL shape, and context containing `caller` and optional generated tokens. The actions use the token type as their principal type; the sidecar -request supplies a signed token rather than another user entity. +request supplies the caller's signed token. -No default entities, templates, or custom issuers are needed. Current account and -incident facts are supplied by the MCP server. +The MCP server supplies current account and incident facts with each request. For the pinned sidecar, `policy-store/trusted-issuers/tutorial-idp.json` uses this exact shape: @@ -325,18 +383,22 @@ package our policy files.
Required step: Create the shared archive builder -Create these files in the repository-level `shared/` directory and copy their -complete linked contents: +```bash +node ../shared/tools/step/run.mjs p3 archive-builder +``` + +The shared builder files are: - [`shared/policy-store.mjs`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/shared/policy-store.mjs) validates and packages the policy store. - [`shared/policy-store.d.mts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/shared/policy-store.d.mts) supplies its TypeScript declarations.
-Install its build dependencies from P3: +From P3, install the build dependencies and update the MCP client to match the completed app: ```bash -pnpm add --save-dev --save-exact fflate@0.8.3 @cedar-policy/cedar-wasm@4.12.0 +pnpm add --save-exact @modelcontextprotocol/client@2.2.0 +pnpm add --save-dev --save-exact fflate@0.8.3 @cedar-policy/cedar-wasm@4.12.0 @types/node@24.19.0 node ../shared/policy-store.mjs ``` @@ -359,18 +421,25 @@ then waits for Cedarling readiness before starting the MCP server. JWT validation establishes the request subject; it does not authenticate every service that can reach the sidecar. This local trust boundary includes the IdP. +Across hosts, service connections also need authenticated transport, such as +mTLS through a service mesh or reverse proxy.[^5] ### Send current facts to the Cedarling sidecar -Now we'll build the sidecar request from facts the MCP server trusts. Copy the -following files together, then follow the HTTP request below. +Now we'll build the sidecar request from facts the MCP server trusts. Add the +client and handlers, then follow the HTTP request below.
Required step: Add the sidecar client and update MCP handlers -Create [`src/mcp/authorization.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/src/mcp/authorization.ts) -and copy its full contents for the sidecar client and decision logging. -Replace these existing files with their complete linked contents: +```bash +node ../shared/tools/step/run.mjs p3 server +``` + +The new [`src/mcp/authorization.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/src/mcp/authorization.ts) +provides the sidecar client and decision logging. + +Updated files: - [`src/mcp/server.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/src/mcp/server.ts) enforces discovery and operation decisions. - [`src/mcp/availability.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/src/mcp/availability.ts) checks that the expected MCP server is available before chat connects. @@ -380,10 +449,10 @@ Replace these existing files with their complete linked contents: - [`src/mcp/schemas.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/src/mcp/schemas.ts) validates MCP operation inputs. - [`src/app.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/src/app.ts) connects authentication and MCP request handling. - [`src/main.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/src/main.ts) starts the configured MCP server. -- [`src/config/project-config.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/src/config/project-config.ts) supplies the private sidecar URL and timeout. +- [`src/config/project-config.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/src/config/project-config.ts) supplies application, identity, and model-provider settings. - [`tsconfig.build.json`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/tsconfig.build.json) selects the server and CLI files for compilation. -Remove `src/mcp/trace.ts`; authorization now records the actual decisions. +The step removes `src/mcp/trace.ts`; authorization now records the actual decisions.
@@ -396,45 +465,78 @@ Cedarling-specific; do not treat them as generic AuthZEN fields. A valid true decision allows the MCP server to continue; DENY or an unavailable response must stop the operation before it returns data or changes an incident. -In the copied `src/mcp/authorization.ts`, `auth` is verified MCP authentication, +In the copied `src/mcp/authorization.ts`, `authorize()` receives verified MCP +authentication as `auth`. Within the function, `subject` identifies the signed-in user, `roles` is the server account map, and `resource` -was loaded by the operation. `action` is a server-selected action name. The -expanded HTTP call is: +was loaded by the operation. `action` is a server-selected action name. +The `request` parameter defaults to `fetch`. Logging is omitted from this excerpt: ```ts // src/mcp/authorization.ts -const response = await fetch("http://127.0.0.1:5000/cedarling/evaluation", { - method: "POST", - headers: { "content-type": "application/json" }, - redirect: "error", - signal: AbortSignal.timeout(2_000), - body: JSON.stringify({ - subject: { - type: "JWT", - id: subject, - properties: { - tokens: [ - { mapping: "P3IncidentAssistant::Access_token", payload: auth.token }, - ], +export async function authorize( + auth: AuthInfo, + action: Action, + resource: Resource, + requestId: string, + request: typeof fetch = fetch, +): Promise { + const subject = parsePersona( + typeof auth.extra?.subject === "string" ? auth.extra.subject : undefined, + ); + let decision: boolean; + // ... prepare request details and HTTP status for logging. + try { + const response = await request( + "http://127.0.0.1:5000/cedarling/evaluation", + { + method: "POST", + headers: { "content-type": "application/json" }, + redirect: "error", + signal: AbortSignal.timeout(2_000), + body: JSON.stringify({ + subject: { + type: "JWT", + id: subject, + properties: { + tokens: [ + { + mapping: "P3IncidentAssistant::Access_token", + payload: auth.token, + }, + ], + }, + }, + action: { name: `P3IncidentAssistant::Action::"${action}"` }, + resource: { + type: resource.type, + id: resource.id, + properties: { + cedar_entity_mapping: { + entity_type: `P3IncidentAssistant::${resource.type}`, + id: resource.id, + }, + ...(resource.type === "Incident" && resource.assignedTo !== null + ? { assigned_to: resource.assignedTo } + : {}), + }, + }, + context: { caller: { subject, role: roles[subject] } }, + }), }, - }, - action: { name: `P3IncidentAssistant::Action::"${action}"` }, - resource: { - type: resource.type, - id: resource.id, - properties: { - cedar_entity_mapping: { - entity_type: `P3IncidentAssistant::${resource.type}`, - id: resource.id, - }, - ...(resource.type === "Incident" && resource.assignedTo !== null - ? { assigned_to: resource.assignedTo } - : {}), - }, - }, - context: { caller: { subject, role: roles[subject] } }, - }), -}); + ); + // ... capture the HTTP status for logging. + if (!response.ok) throw new Error("Sidecar HTTP failure"); + const result = decisionResponse.parse(await response.json()); + if (result.context.id === "-1") + throw new Error("Sidecar evaluation failed"); + decision = result.decision; + } catch { + // ... log authorization.failed with bounded fields. + throw new AuthorizationError("authorization_unavailable"); + } + // ... log authorization.decision. + return decision; +} ``` `JSON.stringify()` supplies the HTTP request body as JSON here.[^4] @@ -442,8 +544,8 @@ const response = await fetch("http://127.0.0.1:5000/cedarling/evaluation", { The module rejects HTTP errors and timeouts, and requires a boolean `decision` and object `context` in the response. This pinned sidecar can report runtime failure in an HTTP 200 body with `context.id === "-1"`; the module -treats that as `authorization_unavailable`, not a policy denial. Only a valid -true decision permits the operation. For an unassigned incident, the request omits +treats that as `authorization_unavailable`, not a policy denial. +For an unassigned incident, the request omits the optional assignment instead of sending `null` to a string field. ### Check permission before each MCP operation @@ -459,22 +561,39 @@ is not permission to use it against every resource. - Triage requires `Triage` on the incident loaded by the server before producing its prompt. - Status updates require `UpdateStatus` before changing the stored incident. -The update handler waits for permission before calling the repository: +Inside `createIncidentMcpServer()`, the `update_incident_status` callback waits for +permission before calling the repository: ```ts // src/mcp/server.ts -const current = currentIncident(incidentId); -await requireAllowed("UpdateStatus", { - type: "Incident", - id: current.id, - assignedTo: current.assignedTo, -}); -const incident = services.incidents.updateStatus({ - incidentId, - expectedStatus, - nextStatus, - idempotencyKey, -}); +// Inside createIncidentMcpServer(): +server.registerTool( + "update_incident_status", + { + title: "Update incident status", + description: "Advance one incident through one confirmed lifecycle step.", + inputSchema: updateIncidentInput, + }, + async ({ incidentId, expectedStatus, nextStatus, idempotencyKey }) => { + try { + const current = currentIncident(incidentId); + await requireAllowed("UpdateStatus", { + type: "Incident", + id: current.id, + assignedTo: current.assignedTo, + }); + const incident = services.incidents.updateStatus({ + incidentId, + expectedStatus, + nextStatus, + idempotencyKey, + }); + // ... return the incident ID, status, version, and request ID. + } catch (error) { + return safeToolError(error); + } + }, +); ``` `requireAllowed` throws on DENY; unavailable decisions also stop execution. @@ -494,37 +613,29 @@ so the archive and sidecar are ready before the MCP server starts.
Required step: Configure the sidecar and update stack startup -Create [`sidecar-bootstrap.json`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/sidecar-bootstrap.json) -and copy its full contents to configure the archive, token validation, and logs. -Replace these existing files with their complete linked contents: +```bash +node ../shared/tools/step/run.mjs p3 startup +``` + +The new [`sidecar-bootstrap.json`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/sidecar-bootstrap.json) +configures the archive, token validation, and logs. + +Updated files: - [`src/chat/host.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/src/chat/host.ts) uses discovered capabilities and handles denied or unavailable operations. - [`scripts/setup.mjs`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/scripts/setup.mjs) prepares host chat configuration. - [`Dockerfile`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/Dockerfile) builds the app and policy archive. - [`compose.yaml`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/compose.yaml) starts the private sidecar and dependent services. -Remove `scripts/dev.mjs`; Compose will manage these services. +The step removes `scripts/dev.mjs`; Compose will manage these services. -
- -In `package.json`, set `dev` to -`docker compose up --build` and `start` to `docker compose up`, then apply the -`build` entry below. Keep the other dependencies and scripts; the -[complete manifest](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/package.json) -shows the completed configuration: +The step also updates `build` to package policies and `dev`/`start` to use Compose. -```json -{ - "scripts": { - "build": "node ../shared/policy-store.mjs && tsc -p tsconfig.build.json" - } -} -``` +
The host `scripts/setup.mjs` prepares chat configuration; Compose's configure -service prepares the container IdP configuration. Host setup must not rewrite -that container's issuer. `pnpm dev` runs Compose; `pnpm chat amir` remains a host -command. Greetings can return a short help response without an MCP operation. +service prepares the container IdP configuration. `pnpm dev` runs Compose; +`pnpm chat amir` remains a host command. Run `pnpm build`, then `pnpm run setup` and `pnpm dev` from the host. Wait for the IdP, archive service, sidecar, and MCP server to become ready. @@ -537,14 +648,6 @@ then repeat the unassigned update that succeeded before Cedarling. ### Complete Amir's assigned work -The client defaults to `liquid/lfm-2.5-2.6b:free` with free routing. -To try another free tool-calling model, set `P3_OPENROUTER_MODEL` in -`.env`. A paid model requires both a paid model ID and -`P3_OPENROUTER_ALLOW_PAID=true`; do this only if you intend to use your paid -credit. Model answers and tool choices can differ even when the authorization -result is the same. The provider may retain prompts for training; use only -fictional incident data. - With fresh sample incidents, enter separately: ```text @@ -557,11 +660,10 @@ Advance INC-1001 from open to investigating. Confirm only the intended update with `y`. Find the incident again to verify its new status. Declining confirmation must leave it unchanged. -Now repeat Amir's baseline attempt on `INC-2001`. Search returns no matching -incident. Triage or update attempts are denied; its mitigated state is unchanged. - -Dana has no assignment to `INC-2001` either. Can she advance it? Check the -supervisor condition in the policy before trying the cases below: +Now search for `INC-2001` as Amir. Expect no matching incident; triage or update +attempts must be denied. We'll verify its state as Dana in the direct exercise +below, since Amir cannot read it. Save Dana's update for that exercise. +Why can she update it without an assignment? Check the supervisor condition: | Caller and attempt | Expected outcome | | --------------------------------------------- | ----------------------------------------------- | @@ -595,48 +697,136 @@ without printing tokens or raw sidecar errors. ### Try direct calls and a sidecar outage -We've checked the chat path. The same restrictions must hold without the chat -interface and when the decision service fails. Keep a before-and-after capture of Amir's attempt to resolve unassigned -`INC-2001`, plus a successful update to assigned `INC-1001`. A denial matters -only when the incident remains unchanged. An MCP client with a valid Amir token -must not be able to bypass this by calling `update_incident_status` directly -with `incidentId: "INC-2001"`, `expectedStatus: "mitigated"`, and -`nextStatus: "resolved"`; the same server-side gate handles direct and -model-selected calls. - -To try a sidecar outage, stop only the sidecar with -`docker compose stop cedarling`, then attempt an otherwise permitted operation. -The server must report authorization unavailable and leave the incident -unchanged. Restore it with `docker compose start cedarling`. Restart the stack -between exercises to restore sample incidents; a resolved incident cannot be -resolved again. +We'll use `scripts/mcp-request.ts` to send requests without a model. If chat +failed, use the provider note in [the starting exercise](#run-the-starting-application) +for model settings; these direct commands need no OpenRouter key. + +First reset the in-memory incidents, leaving configuration volumes intact: + +```bash +docker compose restart incident-assistant +``` + +Wait for the MCP server's listening message in the service terminal. Run the +following commands separately, signing in as the account named in each one. +Dana's first search should show `INC-2001` as `mitigated`, version `1`: + +```bash +pnpm exec tsx scripts/mcp-request.ts dana search INC-2001 +pnpm exec tsx scripts/mcp-request.ts amir search INC-2001 +pnpm exec tsx scripts/mcp-request.ts amir update INC-2001 mitigated resolved +pnpm exec tsx scripts/mcp-request.ts dana search INC-2001 +``` + +Amir's search returns an empty list. Confirm his update with `y`: expect +`authorization_denied` and a nonzero command exit status. Dana's second search +must still show `mitigated`, version `1`. An input-validation error would not +prove this authorization check. + +Now confirm a permitted update for each caller: + +```bash +pnpm exec tsx scripts/mcp-request.ts amir update INC-1001 open investigating +pnpm exec tsx scripts/mcp-request.ts amir search INC-1001 +pnpm exec tsx scripts/mcp-request.ts dana update INC-2001 mitigated resolved +pnpm exec tsx scripts/mcp-request.ts dana search INC-2001 +``` + +Both incidents should reach version `2`, with the requested status. The helper +sends `confirmed: true` only after `y` and generates an idempotency key for each +invocation. To repeat this sequence, restart `incident-assistant` first; a +resolved incident cannot be resolved again. + +For the outage check, use `INC-1002`, which is still `investigating`, version `1`: + +```bash +pnpm exec tsx scripts/mcp-request.ts amir search INC-1002 +docker compose stop cedarling +pnpm exec tsx scripts/mcp-request.ts amir update INC-1002 investigating mitigated +docker compose logs --tail 30 incident-assistant +docker compose start cedarling +``` + +The direct command exits unsuccessfully with a safe failure message. It may fail +to connect before reaching confirmation. An existing chat can report a generic +MCP failure; a fresh MCP client can fail during protocol negotiation. In the +server logs, look for `authorization.failed`, action `Discover`, and code +`authorization_unavailable`. This is an unavailable decision service, not a +policy denial. + +Wait until `docker compose ps cedarling` shows it is healthy, then search again: + +```bash +pnpm exec tsx scripts/mcp-request.ts amir search INC-1002 +``` + +The incident must remain `investigating`, version `1`. Keep this evidence with +the denied update and the successful assigned update. + +### Run the matching project checks + +We've observed the real decisions. Let's add the checks for the integrated app +to this same checkout so its test suite exercises the rules we've just added.
-Warning: Before deploying this application +Required step: Add the matching tests and check configuration -Replace local HTTP and the bundled learning IdP with HTTPS and a configured -OIDC/OAuth issuer, such as [Jans Auth](https://docs.jans.io/stable/janssen-server/planning/use-cases/), -Gluu, Auth0, or Okta. +```bash +node ../shared/tools/step/run.mjs p3 checks +``` -Production needs a database that keeps incidents after restarts, managed user -permissions, protected credentials, and stored audit logs. The lab keeps a fixed -account map and incidents in memory; free-model chat may be unavailable. +Updated files: -Across hosts, restrict the sidecar network and authenticate service connections -with mTLS through a service mesh[^5] or reverse proxy. JWT validation alone does -not establish which service is calling the sidecar. +- [`eslint.config.js`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/eslint.config.js) configures linting for the project scripts and tests. +- [`test/config.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/test/config.test.ts) checks application, issuer, and provider settings. +- [`test/helpers.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/test/helpers.ts) supplies authenticated test callers and controlled decision dependencies. +- [`test/protocol.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/test/protocol.test.ts) checks MCP discovery and operation responses. +- [`test/scenario.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/test/scenario.test.ts) exercises role and assignment restrictions through the chat host. +- [`test/setup.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/test/setup.test.ts) checks host configuration setup. -For a production stack, consider Agama Lab Policy Designer for policy authoring, -Jans Auth for issuing tokens, and Lock Server for centralized decision logs. -See [Cedarling production solutions](https://cedarling.dev/solutions). +New files: -These steps were prepared on Ubuntu 24.04+. Native project checks also run in CI -on macOS and Windows. If a platform-specific step fails, -[open an issue](https://github.com/GluuFederation/cedarling-tutorials/issues). +- [`test/authorization.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/test/authorization.test.ts) checks sidecar requests, response validation, and failure handling with controlled HTTP responses. +- [`test/policy-store.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/test/policy-store.test.ts) checks archive contents, reproducibility, and issuer discovery configuration. +- [`test/mcp-request.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/test/mcp-request.test.ts) checks direct-client arguments, confirmation, errors, and cleanup. +- [`scripts/test-e2e.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/scripts/test-e2e.ts) starts and cleans up an isolated Docker stack for verification. +- [`scripts/verify-stack.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p3-mcp-capability-governance/scripts/verify-stack.ts) exercises real signed tokens and sidecar decisions, including outage and recovery. + +The step also updates `test:e2e` to run the isolated stack checks. +The other baseline tests and the existing `check` script stay in place.
-## What we've learned +From this checkout's P3 directory, run: + +```bash +pnpm format +pnpm check +pnpm test:e2e +``` + +`pnpm check` runs formatting, lint, types, tests, and build. The separate +`test:e2e` command uses real Device Flow tokens and the pinned sidecar, with +scripted model selections instead of OpenRouter. It verifies role and assignment +rules, denied direct calls, confirmation, and unavailable decisions. It uses +its own containers and configuration volumes and removes them afterward; +your exercise stack stays running. Neither command needs a provider key. + +
+Warning: Learning project only + +This project is for learning only and is not intended for production use. +Adapting it requires a separate review of local HTTP and the development IdP, +fixed account permissions, in-memory incidents, sidecar access, provider data +handling, and audit logging. + +Optional components include Jans Auth for identity, Agama Lab Policy Designer +for policy authoring, and Lock Server for decision logs. See +[Cedarling production solutions](https://cedarling.dev/solutions). + +
+ +## Recap: permissions for MCP operations Amir could confirm an update to an unassigned incident in the starting app. We've now blocked that operation at the MCP server while keeping his assigned @@ -662,3 +852,5 @@ permission changes. [^4]: Here `JSON.stringify()` creates the JSON body for an HTTP POST to the Cedarling sidecar. P3 uses the sidecar's AuthZen endpoint, not the `cedarling_wasm` JavaScript authorization method. [^5]: [Mutual TLS (mTLS)](https://istio.io/latest/docs/concepts/security/#mutual-tls-authentication) authenticates both ends of a service connection. A service mesh can manage that transport between application and policy decision service; the local tutorial uses a private shared network namespace instead. + +[^6]: [Token-Based Access Control (TBAC)](https://docs.jans.io/stable/cedarling/#proof-based-authorization-token-based-access-control-tbac) uses tokens from trusted issuers as evidence in an authorization decision. P3 supplies current account roles and incident assignments alongside that evidence. diff --git a/p3-mcp-capability-governance/scripts/mcp-request.ts b/p3-mcp-capability-governance/scripts/mcp-request.ts new file mode 100644 index 0000000..660a774 --- /dev/null +++ b/p3-mcp-capability-governance/scripts/mcp-request.ts @@ -0,0 +1,161 @@ +import { randomUUID } from "node:crypto"; +import { createInterface } from "node:readline/promises"; +import { stdin, stdout } from "node:process"; +import { pathToFileURL } from "node:url"; +import { authorizePersona } from "../src/auth/cli.js"; +import { loadProjectEnvironment } from "../src/config/environment.js"; +import { loadConfig } from "../src/config/project-config.js"; +import { parsePersona } from "../src/incidents/types.js"; +import { McpClientSession } from "../src/mcp/client.js"; +import { + searchIncidentsInput, + updateIncidentInput, +} from "../src/mcp/schemas.js"; + +type Connection = Pick; +type Dependencies = Readonly<{ + loadEnvironment: () => void; + config: typeof loadConfig; + authorize: typeof authorizePersona; + connect: (options: { + endpoint: string; + accessToken: string; + }) => Promise; + confirm: (question: string) => Promise; + key: () => string; + output: (message: string) => void; + error: (message: string) => void; +}>; + +const defaults: Dependencies = { + loadEnvironment: loadProjectEnvironment, + config: loadConfig, + authorize: authorizePersona, + connect: (options) => McpClientSession.connect(options), + confirm: async (question) => { + const prompt = createInterface({ input: stdin, output: stdout }); + try { + return ( + (await prompt.question(`${question} [y/N] `)).trim().toLowerCase() === + "y" + ); + } finally { + prompt.close(); + } + }, + key: randomUUID, + output: (message) => console.log(message), + error: (message) => console.error(message), +}; + +function operation(args: readonly string[], key: () => string) { + const persona = parsePersona(args[0]); + if (args[1] === "search" && args.length === 3) { + return { + persona, + name: "search_incidents", + arguments: searchIncidentsInput.parse({ query: args[2], limit: 5 }), + confirmation: undefined, + }; + } + if (args[1] === "update" && args.length === 5) { + const input = updateIncidentInput.parse({ + incidentId: args[2], + expectedStatus: args[3], + nextStatus: args[4], + confirmed: true, + idempotencyKey: key(), + }); + return { + persona, + name: "update_incident_status", + arguments: input, + confirmation: `Advance ${input.incidentId} from ${input.expectedStatus} to ${input.nextStatus}?`, + }; + } + throw new Error("Invalid command"); +} + +const safeErrors = new Set([ + "authorization_denied", + "authorization_unavailable", + "operation_unavailable", + "incident_not_found", + "stale_incident_state", + "invalid_incident_transition", + "idempotency_conflict", +]); + +/** Sends one authenticated MCP request without a model or a saved access token. */ +export async function runMcpRequest( + args: readonly string[], + overrides: Partial = {}, +): Promise { + const dependencies = { ...defaults, ...overrides }; + let request: ReturnType; + try { + request = operation(args, dependencies.key); + } catch { + dependencies.error( + "Usage: pnpm exec tsx scripts/mcp-request.ts search \n" + + " or: pnpm exec tsx scripts/mcp-request.ts update ", + ); + return 2; + } + + let connection: Connection | undefined; + let exitCode = 1; + try { + dependencies.loadEnvironment(); + const config = dependencies.config(); + const token = await dependencies.authorize(request.persona, config); + connection = await dependencies.connect({ + endpoint: config.mcpResource, + accessToken: token, + }); + if ( + request.confirmation && + !(await dependencies.confirm(request.confirmation)) + ) { + dependencies.output("Status change cancelled."); + exitCode = 0; + } else { + const result = await connection.callToolDirect( + request.name, + request.arguments, + ); + if (result.isError) { + const code = result.content.find( + (item) => item.type === "text" && safeErrors.has(item.text), + ); + dependencies.error( + code?.type === "text" ? code.text : "operation_unavailable", + ); + } else { + dependencies.output( + JSON.stringify(result.structuredContent ?? result.content, null, 2), + ); + exitCode = 0; + } + } + } catch { + dependencies.error( + "Direct MCP request failed. Check sign-in, service logs, and incident state before retrying.", + ); + } finally { + try { + await connection?.close(); + } catch { + dependencies.error("Could not close the MCP connection."); + exitCode = 1; + } + } + return exitCode; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + process.exitCode = await runMcpRequest(process.argv.slice(2)); +} diff --git a/p3-mcp-capability-governance/test/mcp-request.test.ts b/p3-mcp-capability-governance/test/mcp-request.test.ts new file mode 100644 index 0000000..dc32595 --- /dev/null +++ b/p3-mcp-capability-governance/test/mcp-request.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, vi } from "vitest"; +import { runMcpRequest } from "../scripts/mcp-request.js"; +import { loadConfig } from "../src/config/project-config.js"; + +function fixture() { + const callToolDirect = vi.fn().mockResolvedValue({ + content: [], + structuredContent: { incidents: [] }, + }); + const close = vi.fn().mockResolvedValue(undefined); + return { + callToolDirect, + close, + dependencies: { + loadEnvironment: vi.fn(), + config: () => loadConfig({}), + authorize: vi.fn().mockResolvedValue("private-access-token"), + connect: vi.fn().mockResolvedValue({ callToolDirect, close }), + confirm: vi.fn().mockResolvedValue(true), + key: vi.fn().mockReturnValue("fresh-request-1234"), + output: vi.fn(), + error: vi.fn(), + }, + }; +} + +const update = ["amir", "update", "INC-2001", "mitigated", "resolved"]; + +describe("provider-free MCP requests", () => { + it("authenticates and searches through the real client interface without a provider key", async () => { + const { dependencies, callToolDirect, close } = fixture(); + expect( + await runMcpRequest(["amir", "search", "INC-2001"], dependencies), + ).toBe(0); + expect(dependencies.authorize).toHaveBeenCalledWith("amir", loadConfig({})); + expect(dependencies.connect).toHaveBeenCalledWith({ + endpoint: "http://localhost:17003/mcp", + accessToken: "private-access-token", + }); + expect(callToolDirect).toHaveBeenCalledWith("search_incidents", { + query: "INC-2001", + limit: 5, + }); + expect(dependencies.confirm).not.toHaveBeenCalled(); + expect(dependencies.output).toHaveBeenCalledWith( + JSON.stringify({ incidents: [] }, null, 2), + ); + expect(JSON.stringify(dependencies.output.mock.calls)).not.toContain( + "private-access-token", + ); + expect(close).toHaveBeenCalledOnce(); + }); + + it("confirms an explicit update and supplies a fresh idempotency key", async () => { + const { dependencies, callToolDirect, close } = fixture(); + dependencies.key + .mockReturnValueOnce("first-request-1234") + .mockReturnValueOnce("second-request-5678"); + expect(await runMcpRequest(update, dependencies)).toBe(0); + expect(await runMcpRequest(update, dependencies)).toBe(0); + expect(dependencies.confirm).toHaveBeenCalledWith( + "Advance INC-2001 from mitigated to resolved?", + ); + for (const [index, key] of [ + "first-request-1234", + "second-request-5678", + ].entries()) { + expect(callToolDirect).toHaveBeenNthCalledWith( + index + 1, + "update_incident_status", + { + incidentId: "INC-2001", + expectedStatus: "mitigated", + nextStatus: "resolved", + confirmed: true, + idempotencyKey: key, + }, + ); + } + expect(close).toHaveBeenCalledTimes(2); + }); + + it("cancels without invoking the tool", async () => { + const { dependencies, callToolDirect, close } = fixture(); + dependencies.confirm.mockResolvedValue(false); + expect(await runMcpRequest(update, dependencies)).toBe(0); + expect(callToolDirect).not.toHaveBeenCalled(); + expect(dependencies.output).toHaveBeenCalledWith( + "Status change cancelled.", + ); + expect(close).toHaveBeenCalledOnce(); + }); + + it.each([ + [], + ["unknown", "search", "INC-2001"], + ["amir", "search"], + ["amir", "search", " "], + ["amir", "search", "x".repeat(121)], + ["amir", "search", "INC-2001", "extra"], + ["amir", "delete", "INC-2001"], + ["amir", "update", "not-an-id", "mitigated", "resolved"], + ["amir", "update", "INC-2001", "unknown", "resolved"], + [...update, "extra"], + ])("rejects invalid arguments before sign-in: %j", async (...args) => { + const { dependencies } = fixture(); + expect(await runMcpRequest(args, dependencies)).toBe(2); + expect(dependencies.loadEnvironment).not.toHaveBeenCalled(); + expect(dependencies.authorize).not.toHaveBeenCalled(); + expect(dependencies.connect).not.toHaveBeenCalled(); + }); + + it.each([ + "authorization_denied", + "stale_incident_state", + "authorization_unavailable", + ])("reports %s as a failed request", async (code) => { + const { dependencies, callToolDirect, close } = fixture(); + callToolDirect.mockResolvedValue({ + isError: true, + content: [{ type: "text", text: code }], + }); + expect(await runMcpRequest(update, dependencies)).toBe(1); + expect(dependencies.error).toHaveBeenCalledWith(code); + expect(close).toHaveBeenCalledOnce(); + }); + + it("does not print an unrecognized remote error body", async () => { + const { dependencies, callToolDirect } = fixture(); + callToolDirect.mockResolvedValue({ + isError: true, + content: [{ type: "text", text: "private-access-token" }], + }); + expect(await runMcpRequest(update, dependencies)).toBe(1); + expect(dependencies.error).toHaveBeenCalledWith("operation_unavailable"); + expect(JSON.stringify(dependencies.error.mock.calls)).not.toContain( + "private-access-token", + ); + }); + + it.each(["authorize", "connect", "invoke", "confirm"])( + "sanitizes a %s failure and closes any established connection", + async (stage) => { + const { dependencies, callToolDirect, close } = fixture(); + const failure = new Error("private-access-token"); + if (stage === "authorize") + dependencies.authorize.mockRejectedValue(failure); + else if (stage === "connect") + dependencies.connect.mockRejectedValue(failure); + else if (stage === "confirm") + dependencies.confirm.mockRejectedValue(failure); + else callToolDirect.mockRejectedValue(failure); + expect(await runMcpRequest(update, dependencies)).toBe(1); + expect(JSON.stringify(dependencies.error.mock.calls)).not.toContain( + "private-access-token", + ); + expect(close).toHaveBeenCalledTimes( + stage === "invoke" || stage === "confirm" ? 1 : 0, + ); + if (stage === "authorize") + expect(dependencies.connect).not.toHaveBeenCalled(); + }, + ); + + it("reports a cleanup failure without exposing its details", async () => { + const { dependencies, close } = fixture(); + close.mockRejectedValue(new Error("private-access-token")); + expect( + await runMcpRequest(["dana", "search", "INC-2001"], dependencies), + ).toBe(1); + expect(dependencies.error).toHaveBeenCalledWith( + "Could not close the MCP connection.", + ); + }); +}); diff --git a/p4-editorial-publishing/README.md b/p4-editorial-publishing/README.md index b4b0df0..b6d86b3 100644 --- a/p4-editorial-publishing/README.md +++ b/p4-editorial-publishing/README.md @@ -9,6 +9,9 @@ approval across revisions, and publication after a reviewer's authority is revok Follow the [tutorial](docs/tutorials.md) to add these checks to the [starting application](https://github.com/GluuFederation/cedarling-tutorials/tree/21b0832be4b31271320df992d04e9d97667d0e38/p4-editorial-publishing). +The tutorial uses a [step helper](../shared/tools/step/README.md) +to copy the required files from a pinned commit. + ## Architecture ```mermaid @@ -35,7 +38,7 @@ The commands work from PowerShell, macOS terminals, and Ubuntu shells. ## Run -Start the application and its own IdP: +From `p4-editorial-publishing/`, start the application and its own IdP: ```bash docker compose up --build @@ -47,13 +50,11 @@ For native development, run from this project directory: ```bash pnpm --dir ../shared/identity-provider install --frozen-lockfile -pnpm --dir ../shared/identity-provider build pnpm install --frozen-lockfile -pnpm run setup pnpm dev ``` -`pnpm dev` starts this project's IdP and application together. +`pnpm dev` prepares configuration, builds the IdP, and starts it alongside the app. Setup keeps the application port consistent with its registered URL and preserves your editorial data. @@ -63,6 +64,10 @@ Use `pnpm dev -- --reset` only when you want to restore the tutorial fixtures. ## Exercise +Choose an account below. Use the prefilled username, or enter its lowercase name, +and any non-empty password, such as `cedarling-is-awesome`. These credentials +are for the local tutorial IdP only. + Riley is an author, Ana can review and publish, and Omar has revocable editor authority. All three can create articles in their tenant and edit their own drafts. Try these workflows: diff --git a/p4-editorial-publishing/docs/tutorials.md b/p4-editorial-publishing/docs/tutorials.md index ece3cd8..a6ffac9 100644 --- a/p4-editorial-publishing/docs/tutorials.md +++ b/p4-editorial-publishing/docs/tutorials.md @@ -1,11 +1,12 @@ --- slug: secure-editorial-publishing title: Secure Editorial Publishing with Cedarling -summary: Bind editorial approval to exact content and current authority before a Next.js application publishes it. +summary: Use Cedarling in Next.js to require independent review and publish only the approved revision while reviewer permissions remain valid. order: 50 socialImage: ./assets/social-card.webp socialImageAlt: Next.js Server Actions check current editorial facts with Cedarling before protected publishing effects. -lastVerified: 2026-10-07T19:33:56Z +lastVerified: 2026-10-09T12:21:40Z +lastUpdated: 2026-10-09T17:29:36Z --- # Secure Editorial Publishing with Cedarling @@ -25,7 +26,7 @@ and publisher restrictions will move into the same policy store. By the end, Riley and Ana will still be able to publish reviewed work together, with server checks on article creation, reads, edits, submission, review, and publication. -## Build the integration or try the finished app +## Secure the publishing workflow or try the finished app - To build the integration, start with [Run the starting application](#run-the-starting-application), then add the policies and server checks. - To try the finished app, run the [finished project on main](https://github.com/GluuFederation/cedarling-tutorials/tree/main/p4-editorial-publishing) using its README, then go to [Check approvals and publication](#check-approvals-and-publication). This version already uses Cedarling. @@ -35,21 +36,19 @@ and complete its instructions before continuing. These sections contain the file and changes we'll need.
-What you'll need +Before you start - Git, Node.js 24.21+ within 24.x, and pnpm 10.17.1 for the coding steps. The project supplies its own tutorial identity provider (IdP). - Docker with Compose is optional for running the starting or finished application. Use native Node.js for the coding steps. - Familiarity with TypeScript, Next.js Server Actions, sessions, and basic HTTP requests. - New to Cedarling? [Read the short introduction](https://cedarling.dev/learn/what-is-cedarling) when you need it. - Keep the [Cedar policy syntax](https://docs.cedarpolicy.com/policies/syntax-policy.html) and [Cedar schema syntax](https://docs.cedarpolicy.com/schema/human-readable-schema.html) references handy while editing policies. +- These steps were prepared on Ubuntu 24.04+. The repository also has native CI checks for macOS and Windows. If a platform-specific step fails, [open an issue](https://github.com/GluuFederation/cedarling-tutorials/issues).
-At each copying step, open the linked file on GitHub, choose **Raw**, and copy -its full contents into the stated destination in your baseline checkout. The -short examples explain the parts we'll focus on. Create missing parent -directories first. Paths and commands are relative to -`p4-editorial-publishing/`; repository-level `shared/` files go one directory above it. +Run the commands from `p4-editorial-publishing/`. Paths under `shared/` are relative to +the repository root. ## Meet the authors and editors @@ -80,23 +79,21 @@ the results so we can repeat the same requests after integration. ### Run the starting application -Start from a separate checkout so the examples and resets affect only tutorial -data: +Use a separate checkout for P4, including if you've already followed another +project. This keeps shared files and editorial data separate: ```bash git clone https://github.com/GluuFederation/cedarling-tutorials.git cedarling-p4 cd cedarling-p4 git switch --detach 21b0832be4b31271320df992d04e9d97667d0e38 cd p4-editorial-publishing -docker compose up --build -``` -Open `http://localhost:17004`. P4's IdP runs at `http://localhost:18004`. -Select Riley; the development IdP usually prefills `riley`, so enter it only -if the field is empty. Use a non-empty password such as `cedarling-is-awesome` on the -development IdP, and approve access. +# Prepare the tutorial steps. +git restore --source=850e38fd5dc4758848e3fcb05af40f3a9c68760c --worktree -- ../shared/tools/step +node ../shared/tools/step/run.mjs p4 init --source 850e38fd5dc4758848e3fcb05af40f3a9c68760c +``` -For native startup, run these commands from the project directory: +For the coding path, install the dependencies and start the app from this directory: ```bash pnpm --dir ../shared/identity-provider install --frozen-lockfile @@ -106,8 +103,28 @@ pnpm run setup pnpm dev ``` -This starts the IdP and Next.js together. Do not run native and Docker instances -on the same ports. +`pnpm dev` starts the IdP and Next.js together. + +
+Optional: Run the starting app with Docker + +From `p4-editorial-publishing/`, run: + +```bash +docker compose up --build +``` + +Run either Docker or the native stack, since both use ports 17004 and 18004. +Native runs store exercise data in `.local`; Docker uses its own volume. +Each method keeps its own exercise history. + +
+ +Open `http://localhost:17004`. P4's IdP runs at `http://localhost:18004`. +Select Riley; the development IdP usually prefills `riley`, so enter it only +if the field is empty. Use a non-empty password such as `cedarling-is-awesome`, +and approve access. To switch users during the exercises, open the account menu +and select **Sign out or change account**, then choose the next user. ### Approve your own work, then reuse an old approval @@ -124,23 +141,39 @@ Two further exercises show why publication needs its own decision: 2. As Omar, approve **Partner announcement**. Revoke his authority, then publish as Ana. The baseline still accepts approval from the revoked editor. -For the second exercise, use the command matching your startup method: +For the second exercise, revoke Omar's permission: ```bash -# Native pnpm admin revoke-omar ``` +
+If using Docker: Revoke Omar's permission + ```bash -# Docker docker compose exec cedarpress node --env-file=/run/config/app.env scripts/admin.ts revoke-omar ``` -Capture the reviewer, revision details, and publication result. -Use a separate sample article for each exercise. Stop the baseline -with `Ctrl+C` before editing; for Docker, also run `docker compose down` without -removing its volume. If you started with Docker, install the native dependencies -using the commands above before the coding steps. +
+ +Capture the reviewer, revision details, and publication result. Keep tokens, +cookies, and CSRF values out of screenshots and recordings. +Use a separate sample article for each exercise. Stop the baseline with +`Ctrl+C` before editing. + +
+If using Docker: Switch to native development + +Run `docker compose down` to keep its volume, then install the native +dependencies for the coding steps: + +```bash +pnpm --dir ../shared/identity-provider install --frozen-lockfile +pnpm --dir ../shared/identity-provider build +pnpm install --frozen-lockfile +``` + +
## Where should we check permission? @@ -154,8 +187,14 @@ that check only asks whether the user may publish and an approval exists: ```ts // src/server/authorization.ts (starting checkpoint) -case "publication.publish": - return fact("publisherAuthorityCurrent") && fact("approvalPresent"); +export function baselineAllows(request: AuthorizationRequest): boolean { + const fact = (name: string) => request.facts[name] === true; + switch (request.capability) { + // ... other capability cases omitted. + case "publication.publish": + return fact("publisherAuthorityCurrent") && fact("approvalPresent"); + } +} ``` We'll replace this check with a Cedarling decision that requires approval of @@ -183,7 +222,11 @@ policy-store/
Required step: Create the three policy-store files -Create each file below and copy its complete linked contents: +```bash +node ../shared/tools/step/run.mjs p4 policy-store +``` + +New files: - [`policy-store/metadata.json`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p4-editorial-publishing/policy-store/metadata.json): identifies the store and its version, `1.0.0`. - [`policy-store/schema.cedarschema`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p4-editorial-publishing/policy-store/schema.cedarschema): defines principals, resources, actions, and the facts each request requires. @@ -191,10 +234,8 @@ Create each file below and copy its complete linked contents:
-The application verifies the OIDC session and loads the current database user -before making an unsigned request. It supplies the principal, revision, and -permission records on each call. These three files describe how Cedarling -should evaluate those facts.[^3] +These files define how Cedarling evaluates the current user, revision, and +permission records supplied by the server.[^3] | Design question | P4 answer | | --------------------------------------- | ------------------------------------------------------------------------------------------------- | @@ -214,9 +255,8 @@ outdated writes; the revision version identifies the approved content. ### Choose an action and resource for each operation -With those types in place, we can map each operation to an action and resource. -Every request uses the current user loaded by the server and an action such as -`P4EditorialPublishing::Action::"CreateArticle"`. +With those types in place, we can map each operation to an action such as +`P4EditorialPublishing::Action::"CreateArticle"` and the resource it protects. | Capability | Action | Resource | Additional context | Effect waiting for ALLOW | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | ------------------------------------------------- | ------------------------------------- | @@ -284,6 +324,8 @@ revision's ID, version, and digest. Revoking Omar's editor authority makes `context.approval.reviewer_authority_current` false even when the content hasn't changed. That approval can no longer permit publication. +Publication requires all three groups of checks below to pass: + ```mermaid flowchart TD accTitle: Publication checks the current revision and its approval @@ -338,11 +380,11 @@ in transaction?"} ### Build the archive and load Cedarling From the P4 directory, let's install Cedarling and the tools that package and -validate our policy store: +validate our policy store, along with the Next.js security update: ```bash -pnpm add --save-exact @janssenproject/cedarling_wasm@0.0.468 fflate@0.8.3 -pnpm add --save-dev --save-exact @cedar-policy/cedar-wasm@4.12.0 +pnpm add --save-exact @janssenproject/cedarling_wasm@0.0.468 fflate@0.8.3 next@16.3.8 +pnpm add --save-dev --save-exact @cedar-policy/cedar-wasm@4.12.0 @types/node@24.19.0 ``` We'll use a shared builder to validate the policy files and package them for Cedarling. @@ -350,8 +392,11 @@ We'll use a shared builder to validate the policy files and package them for Ced
Required step: Create the shared archive-builder files -Create these files in the repository's `shared/` directory, one level above -P4. Copy the complete contents from each link: +```bash +node ../shared/tools/step/run.mjs p4 archive-builder +``` + +The builder files go in the repository's `shared/` directory, one level above P4: - [`shared/policy-store.mjs`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/shared/policy-store.mjs): validates and packages the readable policy store into a Cedar archive. - [`shared/policy-store.d.mts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/shared/policy-store.d.mts): provides TypeScript declarations for the builder. @@ -365,29 +410,38 @@ node ../shared/policy-store.mjs ``` The command must finish without validation errors and create `.local/policy-store.cjar`. -Keep the readable source directory in Git; we'll include the generated archive -in the Docker image later. +Keep the readable source directory in Git. We'll replace `src/server/authorization.ts` using the complete file in the next -step. It initializes [Cedarling](https://www.npmjs.com/package/@janssenproject/cedarling_wasm/v/0.0.468) -once through the server runtime. Here, `archivePath` resolves to the generated -`.local/policy-store.cjar`: +step. The server runtime calls `createEditorialAuthorization()` once to load +[Cedarling](https://www.npmjs.com/package/@janssenproject/cedarling_wasm/v/0.0.468). +Its default `archivePath` resolves to `.local/policy-store.cjar` in the project +directory: ```ts // src/server/authorization.ts import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; import { initFromArchiveBytes } from "@janssenproject/cedarling_wasm"; -const archive = new Uint8Array(await readFile(archivePath)); -const cedarling = await initFromArchiveBytes( - { - CEDARLING_APPLICATION_NAME: "P4 Editorial Publishing", - CEDARLING_LOG_TYPE: "memory", - CEDARLING_LOG_TTL: 300, - CEDARLING_STRICT_SCHEMA_VALIDATION: "enabled", - }, - archive, -); +// ... other imports and types omitted. +export async function createEditorialAuthorization( + archivePath = resolve(".local/policy-store.cjar"), +) { + const archive = new Uint8Array(await readFile(archivePath)); + // ... validate metadata and log the policy version and archive digest. + const cedarling = await initFromArchiveBytes( + { + CEDARLING_APPLICATION_NAME: "P4 Editorial Publishing", + CEDARLING_LOG_TYPE: "memory", + CEDARLING_LOG_TTL: 300, + CEDARLING_STRICT_SCHEMA_VALIDATION: "enabled", + }, + archive, + ); + // ... define the authorize callback shown below. + return { authorize, close: () => cedarling.shutDown() }; +} ``` The complete module logs the store version and SHA-256 at startup. It @@ -397,13 +451,17 @@ that explicitly stop the runtime. ### Pass the current revision and approval to Cedarling With the archive ready, we can replace the baseline's authorization check and -connect it to the service and forms. Copy the files in the list below together, -then we'll follow the publication request through them. +connect it to the service and forms, then follow the publication request through +them.
-Required step: Copy the server integration and form files +Required step: Add the server integration and form files + +```bash +node ../shared/tools/step/run.mjs p4 server +``` -Replace the contents of these existing P4 files with the complete linked versions: +Updated files: - [`src/server/authorization.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p4-editorial-publishing/src/server/authorization.ts): loads Cedarling, maps each capability to a request, and records its decision. - [`src/server/models.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p4-editorial-publishing/src/server/models.ts): defines the revision, approval, and authority evidence passed between server modules. @@ -415,66 +473,56 @@ Replace the contents of these existing P4 files with the complete linked version - [`app/articles/[articleId]/page.tsx`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p4-editorial-publishing/app/articles/[articleId]/page.tsx): uses server permission previews to show available actions and feedback. - [`next.config.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p4-editorial-publishing/next.config.ts): keeps the Cedarling package external to the Next.js server bundle. -Create [`app/action-button.tsx`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p4-editorial-publishing/app/action-button.tsx) -and copy its full contents. It displays pending and disabled action buttons +[`app/action-button.tsx`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p4-editorial-publishing/app/action-button.tsx) displays pending and disabled action buttons with an explanation when the action is unavailable.
`src/server/service.ts` validates form input and loads the current article, -revision, latest approval, and publisher permission. The authorization module -turns these into a Cedarling request using `principal`, `article`, `approval`, and -`publisherAuthorityCurrent`. The full file shares this mapping across operations -and handles failures: +revision, latest approval, and publisher permission. Inside +`createEditorialAuthorization()`, the `authorize` callback maps these facts to +`resource` and `context` for the selected capability, then calls Cedarling: ```ts -// src/server/authorization.ts -const result = await cedarling.authorizeUnsigned( - JSON.stringify({ - principal: { - cedar_entity_mapping: { - entity_type: "P4EditorialPublishing::Principal", - id: principal.id, - }, - id: principal.id, - tenant_id: principal.tenantId, - }, - action: 'P4EditorialPublishing::Action::"PublishRevision"', - resource: { - cedar_entity_mapping: { - entity_type: "P4EditorialPublishing::Revision", - id: article.revision.id, - }, - revision_id: article.revision.id, - tenant_id: article.tenantId, - author_id: article.revision.authorId, - version: article.revision.version, - digest: article.revision.digest, - state: article.revision.state, - }, - context: { - publisher_authority_current: publisherAuthorityCurrent, - ...(approval - ? { - approval: { - revision_id: approval.revisionId, - revision_version: approval.revisionVersion, - digest: approval.digest, - reviewer_id: approval.reviewerId, - reviewer_authority_current: approval.authorityCurrent, - }, - } - : {}), - }, - }), -); -for (const log of cedarling.getLogsByRequestId(result.request_id)) { - console.info(JSON.stringify(log, null, 2)); -} -if (result.response.diagnostics.errors.length > 0) throw unavailable(); -return result.decision === true; +// src/server/authorization.ts, inside createEditorialAuthorization() +const authorize: AuthorizeEditorial = async (request) => { + const { principal, capability, requestId } = request; + // ... map request.article (or tenant) to resource, and authority to context. + try { + const result = await cedarling.authorizeUnsigned( + JSON.stringify({ + principal: { + cedar_entity_mapping: { + entity_type: "P4EditorialPublishing::Principal", + id: principal.id, + }, + id: principal.id, + tenant_id: principal.tenantId, + }, + action: `P4EditorialPublishing::Action::"${actions[capability]}"`, + resource, + context, + }), + ); + // ... log authorization.context with the application and Cedarling IDs. + for (const log of cedarling.getLogsByRequestId(result.request_id)) { + console.info(JSON.stringify(log, null, 2)); + } + if (result.response.diagnostics.errors.length > 0) throw unavailable(); + return result.decision === true; + } catch { + // ... log authorization.failed with bounded fields. + throw unavailable(); + } +}; ``` +For `publication.publish`, `resource` describes the current revision: its ID, +tenant, author, version, digest, and state. `context` carries +`publisher_authority_current` and, when present, an `approval` with its revision +ID/version, digest, reviewer ID, and current reviewer authority. These are the +fields the publication policy checks above. + `authorizeUnsigned()` expects a JSON string; `JSON.stringify()` converts the current principal, resource, and context to that format.[^2] The formatted log call makes nested reasons readable in this local exercise. @@ -484,8 +532,6 @@ The catch handler maps Cedarling failures to that error without printing raw request data. A false decision becomes `FORBIDDEN` in the service. The other capabilities use the resources and context in the request table. -Permission always comes from the database, never a form field claiming that an -editor or approval is still valid. ### Check permission before saving the publication @@ -494,27 +540,39 @@ We have a Cedarling result; now the service must act on it. In `publish()`, ```ts // src/server/service.ts -const approval = this.database.latestApproval(articleId); -const publisher = this.database.authority( - session.principal.id, - article.tenantId, - "publisher", -); -await this.allow({ - requestId, - capability: "publication.publish", - principal: session.principal, - article, - publisherAuthorityCurrent: publisher.current, - approval, -}); -this.database.publish( - articleId, - article.tenantId, - session.principal.id, - version, - { publisher, approval }, -); +export class EditorialService { + // ... constructor and other methods omitted. + async publish( + session: Session, + form: FormData, + requestId: string, + ): Promise { + const articleId = this.parseId(form.get("articleId")); + const version = this.parseVersion(form.get("expectedVersion")); + const article = this.current(session, articleId, version); + const approval = this.database.latestApproval(articleId); + const publisher = this.database.authority( + session.principal.id, + article.tenantId, + "publisher", + ); + await this.allow({ + requestId, + capability: "publication.publish", + principal: session.principal, + article, + publisherAuthorityCurrent: publisher.current, + approval, + }); + this.database.publish( + articleId, + article.tenantId, + session.principal.id, + version, + { publisher, approval }, + ); + } +} ``` `this.allow()` throws `forbidden()` when authorization returns false. A failed @@ -537,13 +595,17 @@ show readable outcomes without internal request IDs. ## Finish setup and restart the app -We've connected the decisions to the workflow. Before restarting, let's make -startup and builds package the policy store, and finish the error handling. +We've connected the decisions to the workflow. Before restarting, we'll finish +the startup and error handling, then add checks for our completed integration.
Required step: Replace the setup, packaging, and feedback files -Replace these existing P4 files with their complete linked contents: +```bash +node ../shared/tools/step/run.mjs p4 startup +``` + +Updated files: - [`scripts/setup.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p4-editorial-publishing/scripts/setup.ts): packages the policy store during development setup. - [`Dockerfile`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p4-editorial-publishing/Dockerfile): builds and includes the archive in the runtime image. @@ -551,38 +613,44 @@ Replace these existing P4 files with their complete linked contents: - [`app/error.tsx`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p4-editorial-publishing/app/error.tsx): gives the user a recovery option when the workspace is unavailable. - [`app/styles.css`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p4-editorial-publishing/app/styles.css): styles action feedback and disabled controls in the editorial workspace. -
+The step also updates `build` to package policies before the Next.js build. -Apply the `build` entry shown below to your existing -[`package.json`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p4-editorial-publishing/package.json), keeping -the other dependencies and scripts. +
-Replace the `exclude` list in `tsconfig.json` with the following. The baseline's -test files still reference the authorization gateway we replaced, so exclude -them from this runtime build while keeping application type checking enabled. -We'll use the finished checkout for the current test suite: +
+Required step: Add the checks for the completed integration -```json -{ - "exclude": ["node_modules", "test", "e2e"] -} +```bash +node ../shared/tools/step/run.mjs p4 checks ``` -`scripts/setup.ts` builds the archive for development. The build script runs -the shared builder before Next.js compilation, and the Dockerfile includes -the archive in the application image: +Add these checks before building: TypeScript checks them alongside the application. +We'll run the full suite after the browser exercises. -```json -{ - "scripts": { - "build": "node ../shared/policy-store.mjs && next build" - } -} -``` +Updated files: + +- [`test/authorization.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p4-editorial-publishing/test/authorization.test.ts): checks real Cedarling decisions and safe decision logs. +- [`test/database.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p4-editorial-publishing/test/database.test.ts): checks revision, approval, and transaction invariants. +- [`test/service.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p4-editorial-publishing/test/service.test.ts): checks service enforcement, concurrent changes, and unavailable decisions. +- [`test/support.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p4-editorial-publishing/test/support.ts): supplies isolated databases and sessions for tests. +- [`playwright.config.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p4-editorial-publishing/playwright.config.ts): connects the browser checks to their temporary application stack. +- Repository-level [`shared/dev-supervisor.mjs`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/shared/dev-supervisor.mjs): starts and stops the application and IdP processes used by that stack. + +New files: + +- [`test/config.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p4-editorial-publishing/test/config.test.ts): checks server configuration validation. +- [`e2e/editorial-authorization.e2e.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p4-editorial-publishing/e2e/editorial-authorization.e2e.ts): exercises denied and permitted workflows through the real IdP and browser. +- Repository-level [`shared/browser-test-stack.mjs`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/shared/browser-test-stack.mjs): builds the application and starts the browser test stack with disposable data. + +The step removes `e2e/editorial-gaps.e2e.ts` and updates `test` and `check` +for the integrated suite. The other tests and `tsconfig.json` stay in place. + +
-The `next.config.ts` copied earlier sets `serverExternalPackages` for -`@janssenproject/cedarling_wasm`. This keeps the WASM dependency on the server; -P4 does not run Cedarling in the browser. +Setup, builds, and policy tests now package the archive before using it. The +Dockerfile includes it in the application image. In `next.config.ts`, +`serverExternalPackages` leaves the Cedarling package outside Next.js's server +bundle so Node.js can load it. P4 evaluates authorization in its server service. Stop the baseline development stack completely before restarting; the server runtime is cached and must not keep its old authorization implementation. @@ -594,8 +662,7 @@ pnpm build pnpm dev ``` -The app is available at `http://localhost:17004`. We'll check -that Riley cannot self-approve and Ana can still review and publish his work. +The app is available at `http://localhost:17004`. ## Check approvals and publication @@ -607,19 +674,25 @@ or restore Omar's revoked permission. Reset clears editorial records, sessions, and pending sign-ins, then restores the sample articles and Omar's review permission. Do not reset data you want to keep. -In another terminal, from `p4-editorial-publishing/`, run the command matching -your startup method: +In another terminal, from `p4-editorial-publishing/`, run: ```bash -# Native pnpm reset ``` +
+If using Docker: Reset the sample data + +This clears the Docker stack's editorial records, sessions, and pending sign-ins, +then restores the sample articles and Omar's permission. Keep any data you need +before running: + ```bash -# Docker docker compose exec cedarpress node --env-file=/run/config/app.env scripts/reset.ts ``` +
+ Sign in again as Riley before continuing. ### Try self-approval without the button restriction @@ -630,12 +703,16 @@ The **Approve revision** button is disabled. To check the server also denies app open developer tools on that article's local page and run: ```js -const button = [...document.querySelectorAll('button[type="submit"]')].find( - (element) => element.textContent?.trim() === "Approve revision", -); -if (!button) throw new Error("Submit this article's current draft first"); -button.removeAttribute("disabled"); -button.click(); +(() => { + const actionLabel = "Approve revision"; + const button = [...document.querySelectorAll('button[type="submit"]')].find( + (element) => element.textContent?.trim() === actionLabel, + ); + if (!button) + throw new Error(`Open the article with the ${actionLabel} action`); + button.removeAttribute("disabled"); + button.click(); +})(); ``` Expect “This action is not allowed.” Reload: the revision remains submitted @@ -644,10 +721,11 @@ the real framework form; it does not grant authority. For a direct HTTP check, capture that Server Action POST in the Network panel and replay it using the same authenticated local session. Preserve its -current action header, body, and CSRF value. Do not invent or hard-code a Next.js -action ID, and do not publish the captured credentials. The response can return -HTTP 200 with an `x-action-redirect` containing `error-forbidden`; judge the -application outcome and unchanged record, not the HTTP status alone. +headers, body, and CSRF value. Do not invent or hard-code a Next.js action ID, +and do not publish the captured credentials. With client JavaScript active, the +response can return HTTP 200 with an `x-action-redirect`. A native form submission +returns HTTP 303 with a `Location` header instead. Both redirects contain +`error-forbidden`; check the application outcome and unchanged record as well. ### Publish reviewed work, then change its content or authority @@ -666,10 +744,19 @@ answer with the revocation row below. | Omar approves, then loses editor authority | Ana cannot publish using that approval | | Content or authority changes while a decision is pending | Conflict; no stale authorized write | -Use **Customer migration guide** for the new-revision scenario and **Partner -announcement** for revocation, following the same steps as before integration. -Use **Editorial handbook** to check Ana can approve and publish, or finish -the article Riley created during this exercise. +Repeat the **Customer migration guide** and **Partner announcement** exercises +from before integration. On each article's page, Ana now sees a disabled +**Publish current revision** button. To send the request anyway, rerun the +console snippet above with `actionLabel` set to `"Publish current revision"`. +Expect “This action is not allowed.” in both cases. Reload and check: + +- The migration guide's new revision remains **submitted** and unpublished. +- The partner announcement remains **approved** and unpublished, with Omar's + reviewer authority shown as **Revoked**. + +Then use **Editorial handbook** to check Ana can approve and publish, or finish +the article Riley created during this exercise. Ana can also approve the migration +guide's new revision and publish it once that revision has its own approval. ### Read the decision and publication logs @@ -691,15 +778,14 @@ Logs kept in memory expire after five minutes, so they are not a lasting audit r ### Check concurrent changes and unavailable decisions -We've checked the workflow and its logs. The automated suite also exercises -concurrent changes and unavailable authorization. Run it from a separate -checkout of the [finished project on main](https://github.com/GluuFederation/cedarling-tutorials/tree/main/p4-editorial-publishing), -which contains the current integration tests, and install its locked project -and shared IdP dependencies. -Stop your learner stack to free ports 17004 and 18004, then run: +We've checked the workflow and its logs. The test files we copied also exercise +concurrent changes and unavailable authorization. Run them in the same checkout +where you built the integration. Stop your development stack to free ports +17004 and 18004, then run from `p4-editorial-publishing/`: ```bash pnpm exec playwright install chromium +pnpm format pnpm check ``` @@ -712,33 +798,21 @@ revoked authority, and unavailable Cedarling cannot change protected records. They also change facts while decisions are pending and repeat button tampering and direct Server Action requests through the real IdP and browser. -Record the same self-approval attempt before and after integration: it succeeds -in the baseline and is denied afterward. Also record an allowed publication -of the exact approved revision. Keep tokens, cookies, and CSRF values out of -recordings. -
-Warning: Before deploying this application - -Replace local HTTP and the bundled learning IdP with HTTPS and a configured -OIDC/OAuth issuer, such as [Jans Auth](https://docs.jans.io/stable/janssen-server/planning/use-cases/), -Gluu, Auth0, or Okta. - -Control who can change editorial permissions, protect sessions, and store audit -logs. Keep database transaction and content-version checks alongside policy -decisions. +Warning: Learning project only -For a production stack, consider Agama Lab Policy Designer for policy authoring, -Jans Auth for issuing tokens, and Lock Server for centralized decision logs. -See [Cedarling production solutions](https://cedarling.dev/solutions). +This project is for learning only and is not intended for production use. +A production application needs HTTPS and a production OIDC provider in place +of the local HTTP/IdP setup, plus a review of permission administration, +session and storage controls, and durable audit logging. -These steps were prepared on Ubuntu 24.04+. Native project checks also run in CI -on macOS and Windows. If a platform-specific step fails, -[open an issue](https://github.com/GluuFederation/cedarling-tutorials/issues). +For those components, consider Agama Lab Policy Designer for policy authoring, +Jans Auth for identity, and Lock Server for centralized decision logs: +[Cedarling production solutions](https://cedarling.dev/solutions).
-## What we've learned +## Recap: approval and publication We started with an app that accepted self-approval and reused approvals after the content or reviewer permissions changed. We've now required an independent @@ -758,4 +832,4 @@ instead of granting it to everyone who can open a page. [^2]: The pinned [`cedarling_wasm` JavaScript API](https://www.npmjs.com/package/@janssenproject/cedarling_wasm/v/0.0.468) accepts a JSON-string request. Converting to JSON does not verify the values; P4 loads them from its trusted server state. -[^3]: In this application, the server authenticates the user before calling `authorizeUnsigned()`, so the store does not need trusted-issuer mappings. Current facts arrive in each request, and these rules use neither default entities nor templates. Other applications may need those parts of the [policy-store format](https://docs.jans.io/stable/cedarling/reference/cedarling-policy-store/). +[^3]: P4 authenticates the user before calling `authorizeUnsigned()`. It builds the principal and current revision and authority facts from server records. The [policy-store format](https://docs.jans.io/stable/cedarling/reference/cedarling-policy-store/) also supports trusted-issuer mappings for applications that send tokens to Cedarling. diff --git a/p4-editorial-publishing/e2e/editorial-authorization.e2e.ts b/p4-editorial-publishing/e2e/editorial-authorization.e2e.ts index abfe411..0dbd406 100644 --- a/p4-editorial-publishing/e2e/editorial-authorization.e2e.ts +++ b/p4-editorial-publishing/e2e/editorial-authorization.e2e.ts @@ -42,6 +42,40 @@ async function bypassDisabledButton(page: Page, name: string): Promise { await button.click(); } +async function replayForbiddenApproval(page: Page): Promise { + const articlePath = new URL(page.url()).pathname; + const [action] = await Promise.all([ + page.waitForRequest( + (request) => + request.method() === "POST" && + new URL(request.url()).pathname === articlePath, + { timeout: 10_000 }, + ), + bypassDisabledButton(page, "Approve revision"), + ]); + await expect(page.getByRole("status")).toContainText( + "This action is not allowed", + ); + // Replay the actual framework request, not a fabricated action identifier. + const body = action.postDataBuffer(); + if (!body) throw new Error("Missing action form data"); + const actionHeader = action.headers()["next-action"]; + const response = await page.request.post(action.url(), { + data: body, + headers: { + "content-type": action.headers()["content-type"], + ...(actionHeader ? { "next-action": actionHeader } : {}), + origin: new URL(page.url()).origin, + }, + maxRedirects: 0, + }); + // Hydrated actions use an RSC redirect; native forms use an HTTP redirect. + expect(response.status()).toBe(actionHeader ? 200 : 303); + expect( + response.headers()[actionHeader ? "x-action-redirect" : "location"], + ).toContain("error-forbidden"); +} + test("denies the three unsafe editorial workflows and permits valid publication", async ({ browser, }) => { @@ -97,37 +131,25 @@ test("denies the three unsafe editorial workflows and permits valid publication" await expect(riley.getByRole("status")).toContainText( "This action is not allowed", ); - const submittedApproval = riley.waitForRequest( - (request) => - request.method() === "POST" && - Boolean(request.headers()["next-action"]), - ); - await bypassDisabledButton(riley, "Approve revision"); - await expect(riley.getByRole("status")).toContainText( - "This action is not allowed", - ); - // Replay the actual framework request, not a fabricated action identifier. - const action = await submittedApproval; - const body = action.postData(); - if (!body) throw new Error("Missing action form data"); - const response = await riley.request.post(action.url(), { - data: body, - headers: { - "content-type": action.headers()["content-type"], - "next-action": action.headers()["next-action"], - origin: new URL(riley.url()).origin, - }, - maxRedirects: 0, - }); - expect(response.status()).toBe(200); - expect(response.headers()["x-action-redirect"]).toContain( - "error-forbidden", - ); + await replayForbiddenApproval(riley); await bypassDisabledButton(riley, "Publish current revision"); await expect(riley.getByRole("status")).toContainText( "This action is not allowed", ); + // The same Server Action can be submitted before client hydration finishes. + const nativeContext = await browser.newContext({ + javaScriptEnabled: false, + storageState: await rileyContext.storageState(), + }); + try { + const nativePage = await nativeContext.newPage(); + await nativePage.goto(riley.url()); + await replayForbiddenApproval(nativePage); + } finally { + await nativeContext.close(); + } + const ana = await anaContext.newPage(); await signIn(ana, "Ana", "ana"); await openArticle(ana, "Fresh editorial story"); diff --git a/p4-editorial-publishing/package.json b/p4-editorial-publishing/package.json index 57a5d9a..16ef992 100644 --- a/p4-editorial-publishing/package.json +++ b/p4-editorial-publishing/package.json @@ -26,7 +26,7 @@ "@janssenproject/cedarling_wasm": "0.0.468", "better-sqlite3": "13.0.3", "fflate": "0.8.3", - "next": "16.3.6", + "next": "16.3.8", "openid-client": "6.8.8", "react": "19.3.0", "react-dom": "19.3.0", diff --git a/p4-editorial-publishing/pnpm-lock.yaml b/p4-editorial-publishing/pnpm-lock.yaml index 3fef5f0..7b31b57 100644 --- a/p4-editorial-publishing/pnpm-lock.yaml +++ b/p4-editorial-publishing/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: 0.8.3 version: 0.8.3 next: - specifier: 16.3.6 - version: 16.3.6(@playwright/test@1.63.0)(@types/node@24.19.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + specifier: 16.3.8 + version: 16.3.8(@playwright/test@1.63.0)(@types/node@24.19.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) openid-client: specifier: 6.8.8 version: 6.8.8 @@ -275,53 +275,53 @@ packages: '@jridgewell/sourcemap-codec@1.6.0': resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} - '@next/env@16.3.6': - resolution: {integrity: sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ==} + '@next/env@16.3.8': + resolution: {integrity: sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==} - '@next/swc-darwin-arm64@16.3.6': - resolution: {integrity: sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw==} + '@next/swc-darwin-arm64@16.3.8': + resolution: {integrity: sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.3.6': - resolution: {integrity: sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew==} + '@next/swc-darwin-x64@16.3.8': + resolution: {integrity: sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.3.6': - resolution: {integrity: sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q==} + '@next/swc-linux-arm64-gnu@16.3.8': + resolution: {integrity: sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@16.3.6': - resolution: {integrity: sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA==} + '@next/swc-linux-arm64-musl@16.3.8': + resolution: {integrity: sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@16.3.6': - resolution: {integrity: sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw==} + '@next/swc-linux-x64-gnu@16.3.8': + resolution: {integrity: sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@16.3.6': - resolution: {integrity: sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ==} + '@next/swc-linux-x64-musl@16.3.8': + resolution: {integrity: sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@16.3.6': - resolution: {integrity: sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw==} + '@next/swc-win32-arm64-msvc@16.3.8': + resolution: {integrity: sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.3.6': - resolution: {integrity: sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg==} + '@next/swc-win32-x64-msvc@16.3.8': + resolution: {integrity: sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -626,8 +626,8 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - next@16.3.6: - resolution: {integrity: sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw==} + next@16.3.8: + resolution: {integrity: sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -1019,30 +1019,30 @@ snapshots: '@jridgewell/sourcemap-codec@1.6.0': {} - '@next/env@16.3.6': {} + '@next/env@16.3.8': {} - '@next/swc-darwin-arm64@16.3.6': + '@next/swc-darwin-arm64@16.3.8': optional: true - '@next/swc-darwin-x64@16.3.6': + '@next/swc-darwin-x64@16.3.8': optional: true - '@next/swc-linux-arm64-gnu@16.3.6': + '@next/swc-linux-arm64-gnu@16.3.8': optional: true - '@next/swc-linux-arm64-musl@16.3.6': + '@next/swc-linux-arm64-musl@16.3.8': optional: true - '@next/swc-linux-x64-gnu@16.3.6': + '@next/swc-linux-x64-gnu@16.3.8': optional: true - '@next/swc-linux-x64-musl@16.3.6': + '@next/swc-linux-x64-musl@16.3.8': optional: true - '@next/swc-win32-arm64-msvc@16.3.6': + '@next/swc-win32-arm64-msvc@16.3.8': optional: true - '@next/swc-win32-x64-msvc@16.3.6': + '@next/swc-win32-x64-msvc@16.3.8': optional: true '@oxc-project/types@0.151.0': {} @@ -1264,9 +1264,9 @@ snapshots: nanoid@3.3.19: {} - next@16.3.6(@playwright/test@1.63.0)(@types/node@24.19.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + next@16.3.8(@playwright/test@1.63.0)(@types/node@24.19.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: - '@next/env': 16.3.6 + '@next/env': 16.3.8 '@swc/helpers': 0.5.23 baseline-browser-mapping: 2.11.24 caniuse-lite: 1.0.30001810 @@ -1275,14 +1275,14 @@ snapshots: react-dom: 19.3.0(react@19.3.0) styled-jsx: 5.1.6(react@19.3.0) optionalDependencies: - '@next/swc-darwin-arm64': 16.3.6 - '@next/swc-darwin-x64': 16.3.6 - '@next/swc-linux-arm64-gnu': 16.3.6 - '@next/swc-linux-arm64-musl': 16.3.6 - '@next/swc-linux-x64-gnu': 16.3.6 - '@next/swc-linux-x64-musl': 16.3.6 - '@next/swc-win32-arm64-msvc': 16.3.6 - '@next/swc-win32-x64-msvc': 16.3.6 + '@next/swc-darwin-arm64': 16.3.8 + '@next/swc-darwin-x64': 16.3.8 + '@next/swc-linux-arm64-gnu': 16.3.8 + '@next/swc-linux-arm64-musl': 16.3.8 + '@next/swc-linux-x64-gnu': 16.3.8 + '@next/swc-linux-x64-musl': 16.3.8 + '@next/swc-win32-arm64-msvc': 16.3.8 + '@next/swc-win32-x64-msvc': 16.3.8 '@playwright/test': 1.63.0 sharp: 0.35.5(@types/node@24.19.0) transitivePeerDependencies: diff --git a/p5-dataguard/README.md b/p5-dataguard/README.md index dfa52d2..e2b67d3 100644 --- a/p5-dataguard/README.md +++ b/p5-dataguard/README.md @@ -11,6 +11,9 @@ The server authenticates the analyst and enforces those decisions before releasing data. Follow the [tutorial](docs/tutorials.md) to add the checks to the [starting application](https://github.com/GluuFederation/cedarling-tutorials/tree/21b0832be4b31271320df992d04e9d97667d0e38/p5-dataguard). +The tutorial uses a [step helper](../shared/tools/step/README.md) +to copy the required files from a pinned commit. + ## Architecture ```mermaid @@ -35,7 +38,7 @@ Both startup paths include the project's tutorial identity provider. ## Run -Start the application and its own IdP: +From `p5-dataguard/`, start the application and its own IdP: ```bash docker compose up --build @@ -47,16 +50,16 @@ For native development, run from this project directory: ```bash pnpm --dir ../shared/identity-provider install --frozen-lockfile -pnpm --dir ../shared/identity-provider build pnpm install --frozen-lockfile pnpm dev ``` -`pnpm dev` starts this project's IdP and application together. +`pnpm dev` builds the IdP, prepares configuration, and builds and watches the app. Setup keeps the application port consistent with its registered URL and preserves your workforce data and exports. -For a fresh `pnpm build` followed by `pnpm start`, run `pnpm run setup` first. +For compiled startup, run `pnpm run setup`, +`pnpm --dir ../shared/identity-provider build`, and `pnpm build` before `pnpm start`. Start `node --env-file=.local/idp/.env ../shared/identity-provider/dist/main.js` in another terminal in this project directory and keep it running. @@ -64,6 +67,10 @@ Use `pnpm dev -- --reset` only when you want to restore the tutorial fixtures. ## Exercise +Choose an account below. Use the prefilled username, or enter its lowercase name, +and any non-empty password, such as `cedarling-is-awesome`. These credentials +are for the local tutorial IdP only. + Each account has a different reason to use the data: - Amina uses Tenant A operational fields and tenant ID for `support`. She has diff --git a/p5-dataguard/docs/tutorials.md b/p5-dataguard/docs/tutorials.md index 792874d..dcdf628 100644 --- a/p5-dataguard/docs/tutorials.md +++ b/p5-dataguard/docs/tutorials.md @@ -1,11 +1,12 @@ --- slug: protect-sensitive-data-exports title: Protect Sensitive Data Exports with Cedarling -summary: Authorize fields, tenant-bound queries, aggregate disclosure, and CSV export access at their server boundaries. +summary: Control access to sensitive fields, reports, and CSV exports with server-side Cedarling decisions. order: 60 socialImage: ./assets/social-card.webp socialImageAlt: A Hono API checks query plans with embedded Cedarling before releasing rows, aggregates, or CSV exports. lastVerified: 2026-10-07T19:56:00Z +lastUpdated: 2026-10-09T17:29:36Z --- # Protect Sensitive Data Exports with Cedarling @@ -25,7 +26,7 @@ five records per returned group. Creating an export needs a separate decision; downloading or revoking it requires its owner to still have the finance role. Leah's allowed finance reports and exports should keep working. -## Build the integration or try the finished app +## Build the data-access checks or run the complete example - To build the integration, start with [Run the starting application](#run-the-starting-application), then add the policies and server checks. - To try the finished app, run the [finished project on main](https://github.com/GluuFederation/cedarling-tutorials/tree/main/p5-dataguard) using its README, then go to [Check queries and exports for each user](#check-queries-and-exports-for-each-user). This version already uses Cedarling. @@ -35,7 +36,7 @@ and complete its instructions before continuing. These sections contain the file and changes we'll need.
-What you'll need +Before you start - Git, Node.js 24.21+ within 24.x, and pnpm 10.17.1 for the coding steps. The project supplies its own tutorial identity provider (IdP). - Docker with Compose is optional for the baseline or finished example. Use native Node.js for the coding steps. @@ -45,11 +46,8 @@ and changes we'll need.
-At each copying step, open the linked file on GitHub, choose **Raw**, and copy -its full contents into the stated destination in your baseline checkout. The -short examples explain the parts we'll focus on. Create missing parent -directories first. Paths and commands are relative to -`p5-dataguard/`; repository-level `shared/` files go one directory above it. +Run the commands from `p5-dataguard/`. Paths under `shared/` are relative to +the repository root. ## Who should see which data? @@ -58,8 +56,9 @@ directories first. Paths and commands are relative to _Amina, Leah, and Theo may see different parts of the same dataset._ P5 is a React application with a Hono Node.js API and SQLite. The browser -submits a query plan with limits on what it can request, not SQL. It can request -rows or aggregates[^1], such as a count of employees by department. +submits a query plan with limits on what it can request, not SQL. A plan can +request individual rows or an aggregate: a calculation over several records, +such as a count of employees by department.[^1] - **Amina**, Tenant A support analyst: operational fields and tenant ID for `support`; no personal or compensation fields and no exports. @@ -84,22 +83,22 @@ us check the API without relying on which fields the interface offers. ### Run the starting application -Use a new checkout with its own sample data: +Use a separate checkout for P5, including if you've already followed another +project. This keeps shared files and sample data separate: ```bash git clone https://github.com/GluuFederation/cedarling-tutorials.git cedarling-p5 cd cedarling-p5 git switch --detach 21b0832be4b31271320df992d04e9d97667d0e38 cd p5-dataguard -docker compose up --build -``` -Open `http://localhost:17005`. The development IdP is at -`http://localhost:18005`. Select Amina. The development IdP usually prefills -`amina`; enter it if the field is empty. Use a non-empty password such as -`cedarling-is-awesome`, and approve access. +# Prepare the tutorial steps. +git restore --source=850e38fd5dc4758848e3fcb05af40f3a9c68760c --worktree -- ../shared/tools/step +node ../shared/tools/step/run.mjs p5 init --source 850e38fd5dc4758848e3fcb05af40f3a9c68760c +``` -For native startup, run these commands from the project directory: +For the build-along, install the dependencies and start the native development +stack from this project directory: ```bash pnpm --dir ../shared/identity-provider install --frozen-lockfile @@ -108,7 +107,30 @@ pnpm install --frozen-lockfile pnpm dev ``` -Use only one startup method on these ports. +`pnpm dev` prepares the sample data, builds the application, and starts the IdP +and API together. Keep that terminal running while using the app. + +
+Optional: Run the starting app with Docker + +Instead of the native commands above, run: + +```bash +docker compose up --build +``` + +Native and Docker execution use the same ports; run only one at a time. Before +the coding steps, stop Docker with `Ctrl+C`, then `docker compose down`, keeping +its volume. Install the native dependencies with the first three commands above; +wait until the server checkpoint to run `pnpm dev` again. Native execution uses +its own sample database. + +
+ +Open `http://localhost:17005`. The development IdP is at +`http://localhost:18005`. Select Amina. The development IdP usually prefills +`amina`; enter it if the field is empty. Use a non-empty password such as +`cedarling-is-awesome`, and approve access. ### Request salary without using the field picker @@ -140,10 +162,9 @@ separate from SQL instructions, but does not decide whether Amina may see salary The baseline also permits cross-tenant queries, small-group aggregates, and access to another user's exports. Keep this request to repeat after integration. -Capture the response using fictional data only. Stop the baseline before -integrating; for Docker use `Ctrl+C`, then `docker compose down` without deleting -the volume. If you started with Docker, install the native dependencies using -the commands above before the coding steps. +Capture the response using fictional data only. Stop the native stack with +`Ctrl+C` before editing. If you chose Docker, follow its stop and native-install +instructions above. ## Where should we check permission? @@ -155,7 +176,10 @@ it executes the query here: ```ts // src/server/app.ts (starting checkpoint) +// Inside buildApp() > evaluatePlan(): +const compiled = compileQuery(plan); const evaluation = database.evaluate(compiled, compileCardinalityQuery(plan)); +// ... log the baseline ALLOW and return the evaluated result. ``` The log printed afterward has not checked whether Amina may read @@ -191,7 +215,11 @@ policy-store/
Required step: Create the five policy-store files -Create these files and copy their complete linked contents: +```bash +node ../shared/tools/step/run.mjs p5 policy-store +``` + +New files: - [`policy-store/metadata.json`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/policy-store/metadata.json) identifies the store and version. - [`policy-store/schema.cedarschema`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/policy-store/schema.cedarschema) defines analysts, fields, datasets, exports, and request context. @@ -202,9 +230,9 @@ Create these files and copy their complete linked contents:
Use the store's metadata and version `1.0.0`. Namespace `P5DataGuard` contains -four entity types: `Analyst`, `Dataset`, `Field`, and `Export`. OIDC and SQLite -supply identity and current facts, so this store needs no trusted issuers, -default entities, templates, or custom issuers. +four entity types: `Analyst`, `Dataset`, `Field`, and `Export`. The server +authenticates the user through OIDC and loads the current analyst from SQLite. +Each unsigned request carries that analyst and the facts needed for its operation. | Design question | P5 answer | | ------------------------------------------------ | ------------------------------------------------------------------------------------------ | @@ -229,8 +257,8 @@ filter does not meet the tenant requirement. All requests use the current database analyst as principal. Actions below use the `P5DataGuard::Action` namespace. -| Capability | Action | Resource | Context | Effect waiting for ALLOW | -| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | ----------------------------------------------------- | --------------------------------------- | +| Capability | Action | Resource | Context | Effect waiting for ALLOW | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | ----------------------------------------------------- | --------------------------------------- | | `dataset.inspect` | [`InspectDataset`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/policy-store/policies/fields.cedar#L2 "inspect-fields") | Each candidate field | Empty | Return field metadata to React | | `data.query` | [`Query`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/policy-store/policies/plans.cedar#L2 "authorized-plan") | Workforce dataset | Validated row-plan facts | Return rows within the plan's limit | | `data.aggregate` | [`Aggregate`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/policy-store/policies/plans.cedar#L2 "authorized-plan") | Workforce dataset | Plan facts and current minimum group size | Return aggregate values | @@ -305,13 +333,14 @@ when { ``` Knowing an export's download reference does not satisfy its ownership and finance -conditions. The application also checks expiry, reference validity, file -integrity, and whether the export is still active. No matching permit gives DENY. +conditions. The application also checks expiry, reference validity, and whether +the export is still active. No matching permit gives DENY. ## Add Cedarling to the API -We have separate rules for each disclosure. Now we'll call Cedarling from the -API and keep the protected result behind its decision. +Field metadata, query plans, and saved exports reach different API routes. +Each route supplies current facts to Cedarling, then rechecks those facts before +releasing data or changing an export: ```mermaid flowchart TD @@ -328,11 +357,11 @@ flowchart TD ### Build the archive and load Cedarling -Install pinned dependencies from P5: +From P5, install Cedarling and match the completed app's dependencies: ```bash -pnpm add --save-exact @janssenproject/cedarling_wasm@0.0.468 fflate@0.8.3 -pnpm add --save-dev --save-exact @cedar-policy/cedar-wasm@4.12.0 +pnpm add --save-exact @janssenproject/cedarling_wasm@0.0.468 fflate@0.8.3 hono@4.13.7 +pnpm add --save-dev --save-exact @cedar-policy/cedar-wasm@4.12.0 @types/node@24.19.0 ``` We'll use a shared builder to validate the policy files and package them for Cedarling. @@ -340,8 +369,11 @@ We'll use a shared builder to validate the policy files and package them for Ced
Required step: Create the shared archive builder -Create these files in the repository-level `shared/` directory and copy their -complete linked contents: +```bash +node ../shared/tools/step/run.mjs p5 archive-builder +``` + +The shared builder files are: - [`shared/policy-store.mjs`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/shared/policy-store.mjs) validates and packages the policy store. - [`shared/policy-store.d.mts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/shared/policy-store.d.mts) provides its TypeScript declarations. @@ -358,53 +390,67 @@ The command must finish without validation errors and create `.local/policy-store.cjar`. Keep the readable policy source in Git and the generated archive ignored. The complete `src/server/authorization.ts` in the next step initializes one [Cedarling](https://www.npmjs.com/package/@janssenproject/cedarling_wasm/v/0.0.468) -instance: +instance in `createDataAuthorization()`. Its `archivePath` defaults to the +`.local/policy-store.cjar` we just built in the project directory: ```ts // src/server/authorization.ts import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; import { initFromArchiveBytes } from "@janssenproject/cedarling_wasm"; -const archive = new Uint8Array(await readFile(archivePath)); -const cedarling = await initFromArchiveBytes( - { - CEDARLING_APPLICATION_NAME: "P5 DataGuard", - CEDARLING_LOG_TYPE: "memory", - CEDARLING_LOG_TTL: 300, - CEDARLING_STRICT_SCHEMA_VALIDATION: "enabled", - }, - archive, -); +// ... other imports and types omitted. +export async function createDataAuthorization( + archivePath = resolve(".local/policy-store.cjar"), +): Promise { + const archive = new Uint8Array(await readFile(archivePath)); + // ... validate metadata and log the policy version and archive digest. + const cedarling = await initFromArchiveBytes( + { + CEDARLING_APPLICATION_NAME: "P5 DataGuard", + CEDARLING_LOG_TYPE: "memory", + CEDARLING_LOG_TTL: 300, + CEDARLING_STRICT_SCHEMA_VALIDATION: "enabled", + }, + archive, + ); + // ... define log helpers and return inspect(), authorize(), and close(). +} ``` -`archivePath` defaults to `.local/policy-store.cjar` on the server. The module -logs its version and SHA-256. `src/server/main.ts` creates the authorization -functions, passes them to `buildApp()`, and calls `shutDown()` when shutting down. +The module logs the archive's version and SHA-256. `src/server/main.ts` creates +the authorization functions, passes them to `buildApp()`, and calls `shutDown()` +when shutting down. ### Pass the analyst and query facts to Cedarling With the archive ready, let's connect Cedarling to the query and export routes. -Copy the files below together, then follow how they build a request for Amina. +We'll add the server files, then follow how they build a request for Amina.
Required step: Add authorization and update the query and export service -Create these files and copy their complete linked contents: +```bash +node ../shared/tools/step/run.mjs p5 server +``` + +New files: - [`src/server/authorization.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/src/server/authorization.ts) builds Cedarling requests and records decisions. - [`src/server/errors.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/src/server/errors.ts) defines bounded errors for denied or unavailable authorization. -Replace these existing files with their complete linked contents: +Updated files: - [`src/server/app.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/src/server/app.ts) checks permission at each API boundary. - [`src/server/database.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/src/server/database.ts) rechecks current sessions and query facts within transactions. - [`src/server/query.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/src/server/query.ts) validates plans and compiles bounded SQL. -- [`src/server/export-service.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/src/server/export-service.ts) creates CSV files and checks saved export references. +- [`src/server/export-service.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/src/server/export-service.ts) creates, reads, and removes CSV files. The API and database look up their references. - [`src/server/main.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/src/server/main.ts) starts and closes Cedarling with the API. -- [`src/server/config.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/src/server/config.ts) supplies the policy archive location. +- [`src/server/config.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/src/server/config.ts) supplies API, storage, identity, and session settings. - [`src/shared/contracts.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/src/shared/contracts.ts) defines query, export, and permission-preview types. +- [`scripts/setup.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/scripts/setup.ts) prepares configuration and sample data using the updated server modules, and builds the policy archive. -Remove `src/server/permissive-trace.ts`; authorization now records the actual decisions. +The step removes `src/server/permissive-trace.ts`; authorization now records the actual decisions.
@@ -413,41 +459,61 @@ It collects fields used in selected columns, calculations, grouping, and filters It gets their classifications from the server catalog and includes `tenant_id` only when the submitted filter is an exact tenant equality. -In this call, `analyst` comes from the current database session; `action`, -`resource`, and `context` follow the request table. The full file builds the -principal with `principal(analyst)` and logs -decisions through `logDecision()`: +`createDataAuthorization()` returns an `authorize()` method. It receives +the current database analyst and either a query plan or a saved export. +The method builds `resource` and `context` for that operation, then evaluates +the request: ```ts // src/server/authorization.ts -const result = await cedarling.authorizeUnsigned( - JSON.stringify({ - principal: { - cedar_entity_mapping: { - entity_type: "P5DataGuard::Analyst", - id: analyst.id, - }, - id: analyst.id, - tenant_id: analyst.tenantId, - role: analyst.role, - }, - action, - resource, - context, - }), -); -for (const log of cedarling.getLogsByRequestId(result.request_id)) { - console.info(JSON.stringify(log, null, 2)); -} -if (result.response.diagnostics.errors.length > 0) { - throw new AuthorizationError(503, "authorization_unavailable"); -} -return result.decision === true; +// Returned object inside createDataAuthorization(): +return { + // ... inspect() omitted. + async authorize(request) { + const { analyst, capability, requestId } = request; + // ... build resource and context from the plan or saved export. + try { + const result = await cedarling.authorizeUnsigned( + JSON.stringify({ + principal: principal(analyst), + action: `P5DataGuard::Action::"${actions[capability]}"`, + resource, + context, + }), + ); + logDecision( + requestId, + analyst.id, + capability, + request.phase ?? "enforcement", + resource.cedar_entity_mapping, + result.request_id, + ); + if (result.response.diagnostics.errors.length) + throw new Error("Policy evaluation failed"); + return result.decision === true; + } catch { + logFailure( + requestId, + analyst.id, + capability, + request.phase ?? "enforcement", + ); + throw new AuthorizationError(503, "authorization_unavailable"); + } + }, + close: () => cedarling.shutDown(), +}; ``` +The file's `principal(analyst)` helper maps the database user to +`P5DataGuard::Analyst`, including `id`, `tenant_id`, and `role`. +`logDecision()` links the application request to Cedarling's request ID and +prints the matching Cedarling records with `JSON.stringify(log, null, 2)`. + `authorizeUnsigned()` expects a JSON string, so `JSON.stringify()` converts -the server-validated request to that format.[^2] The log formatting -call prints nested reasons for this local exercise. +the server-validated request to that format.[^2] The separate log formatting +call makes nested reasons readable in this local exercise. `AuthorizationError` comes from `src/server/errors.ts`, copied above. The catch handler maps Cedarling failures to `503 authorization_unavailable`. A valid @@ -478,48 +544,59 @@ no unchecked fields. ### Check permission before running the query -We can now build and evaluate the request. The next step is to make the query -wait for its result. Row queries, aggregates, and export creation use `executePlan()` in -`src/server/app.ts`. It requires permission before `database.execute()`: +Row queries, aggregates, and export creation share `executePlan()` in +`src/server/app.ts`. We'll trace where it requires permission before +`database.execute()`: ```ts // src/server/app.ts -requireActiveRequest(context); -const { cardinality, minimumGroupSize } = planFacts(plan); -if ( - !(await authorization.authorize({ - requestId, - analyst: session.user, - capability, - plan, - ...(minimumGroupSize !== undefined ? { minimumGroupSize } : {}), - })) -) - throw new AuthorizationError(403, "authorization_denied"); -return database.withCurrentSession( - getCookie(context, sessionCookie) ?? "", - config, - session, - () => { - // Only the bounded cardinality probe precedes ALLOW; protected values do not. - requireActiveRequest(context); - if ( - cardinality && - database.minimumGroupSize(cardinality) !== minimumGroupSize - ) - throw new AuthorizationError(409, "authorization_state_changed"); - const compiled = compileQuery(plan); - return effect(compiled.outputColumns, database.execute(compiled)); - }, -); +// Inside buildApp(): +async function executePlan( + context: Context, + plan: QueryPlan, + capability: "data.query" | "data.aggregate" | "data.export", + session: Session, + requestId: string, + effect: (columns: string[], rows: QueryResponse["rows"]) => T, +): Promise { + requireActiveRequest(context); + const { cardinality, minimumGroupSize } = planFacts(plan); + if ( + !(await authorization.authorize({ + requestId, + analyst: session.user, + capability, + plan, + ...(minimumGroupSize !== undefined ? { minimumGroupSize } : {}), + })) + ) + throw new AuthorizationError(403, "authorization_denied"); + return database.withCurrentSession( + getCookie(context, sessionCookie) ?? "", + config, + session, + () => { + // Only the bounded cardinality probe precedes ALLOW; protected values do not. + requireActiveRequest(context); + if ( + cardinality && + database.minimumGroupSize(cardinality) !== minimumGroupSize + ) + throw new AuthorizationError(409, "authorization_state_changed"); + const compiled = compileQuery(plan); + return effect(compiled.outputColumns, database.execute(compiled)); + }, + ); +} ``` `planFacts()` supplies the counts needed to check an aggregate. If authorization denies or throws, execution stops before the protected query. After `ALLOW`, `withCurrentSession()` checks the session and analyst's current permissions -inside the transaction. The count is checked again there too. If the session or -access token expired while waiting for the decision, or the analyst or count -changed, it throws `409 authorization_state_changed` without running the query. +inside the transaction and checks the minimum group size again. If the session +or access token expired while waiting for the decision, or the analyst or minimum +group size changed, it throws `409 authorization_state_changed` without running +the query. `effect` uses the allowed rows to build the response or create the CSV. The SQL compiler continues to use fixed identifiers and bound values in @@ -530,15 +607,35 @@ using its stored owner. They recheck expiry, state, and current facts before sending the file or revoking access. Revocation stays saved even if deleting the CSV file then fails; failed cleanup must not restore access. +### Try the server check before updating the controls + +Start the app with the new server files and the existing browser interface: + +```bash +pnpm dev +``` + +The setup file copied above builds the archive before startup. Sign in again as +Amina and repeat the [salary request](#request-salary-without-using-the-field-picker) +in the browser console. Expect **403**, `error: "authorization_denied"`, a +request ID, and no rows. The API now blocks the same disclosure that returned +200 in the baseline. + +Stop the stack with `Ctrl+C` before continuing with the browser files. + ### Show which actions are available -The API protects the results. Let's also give users controls that reflect what -the server currently permits. +We've denied the direct request. Now let's make the query and export controls +reflect the server's decisions.
Required step: Update the query form and export controls -Replace these existing files with their complete linked contents: +```bash +node ../shared/tools/step/run.mjs p5 browser +``` + +Updated files: - [`src/web/App.tsx`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/src/web/App.tsx) applies permission previews to the form and export actions. - [`src/web/api.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/src/web/api.ts) requests previews and handles bounded API errors. @@ -548,68 +645,103 @@ Replace these existing files with their complete linked contents:
`POST /api/authorization` previews the exact selected plan and export operations. -It returns which actions are allowed, not rows or a CSV. React ignores outdated preview -responses and disables controls while checking or when decisions are unavailable. -Each actual query/export request still authorizes again. +It returns which actions are allowed, not rows or a CSV. React ignores outdated +preview responses and disables controls while checking or when decisions are +unavailable. Each actual query/export request still authorizes again. -Export controls use the last completed query, so editing the form does not -silently change the result being exported. These controls use server previews; -there is no browser Cedarling instance or separate client permission table. +Export creation submits the last successful query plan for a new authorization +check and query. Editing the form does not change that plan until the user runs +it. React uses the server's previews for these controls. ## Finish setup and restart the app -The server and controls are connected. Before testing them, let's make sure -native startup and Docker both build the policy archive. +The development setup already builds the archive. Let's include it in standalone +builds and Docker, and update the reset command for the new server files.
-Required step: Update setup, reset, and Docker packaging +Required step: Update reset and Docker packaging + +```bash +node ../shared/tools/step/run.mjs p5 startup +``` -Replace these existing files with their complete linked contents: +Updated files: -- [`scripts/setup.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/scripts/setup.ts) prepares configuration, builds the archive, and seeds data. - [`scripts/reset.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/scripts/reset.ts) restores sample data and clears sessions and exports. - [`Dockerfile`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/Dockerfile) includes the archive in the runtime image. -
+The step also updates `build` to package policies before building the browser and server. -Apply the `build` entry shown below to your existing -[`package.json`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/package.json), keeping -the other dependencies and scripts. +
-The setup script builds the archive; the production build must too. The existing +The existing [`scripts/dev.mjs`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/scripts/dev.mjs) -runs both steps before starting the IdP and API, so `pnpm dev` needs no further -change: +runs setup and build before starting the IdP and API. It stays unchanged. -```json -{ - "scripts": { - "build": "node ../shared/policy-store.mjs && vite build && tsc -p tsconfig.server.json" - } -} +Restart the development stack: + +```bash +pnpm dev ``` -The copied Dockerfile includes `.local/policy-store.cjar` in the runtime image. -For compiled native startup, run `pnpm run setup` and `pnpm build`, keep +
+Optional: Run the compiled app + +Stop the development stack first. For compiled native execution, run +`pnpm run setup` and `pnpm build`. Keep `node --env-file=.local/idp/.env ../shared/identity-provider/dist/main.js` -running in another terminal, and run `pnpm start`. Docker remains -`docker compose up --build`. +running in one terminal, then run `pnpm start` in another. + +
+ +
+Optional: Run the integrated app with Docker -Stop the baseline development stack before restarting it. Run: +Stop the native stack first, then run: ```bash -pnpm run setup -pnpm build -pnpm dev +docker compose up --build ``` -Open `http://localhost:17005` and sign in again as Amina after the IdP restart. -We'll retry her salary request, then check the allowed queries and exports. +Docker uses its own data volume. The copied Dockerfile includes +`.local/policy-store.cjar` in its runtime image. + +
## Check queries and exports for each user -We'll compare the same salary request before and after integration, then check -that Amina, Theo, and Leah can still do their permitted work. +Open `http://localhost:17005` and sign in as Amina. We'll check the updated +controls and direct requests, then try Theo's aggregates and Leah's exports. + +To change users, choose **Open account menu**, then **Change account**. Ordinary +tabs share the app's session. Use separate browser profiles for simultaneous +users, and fetch a fresh `/api/session` response for the CSRF token in each +profile after signing in. + +
+If needed: Reset the native sample data + +For native execution, run `pnpm reset` from this checkout's `p5-dataguard/`. +Reset restores the sample records and clears sessions and exports. Sign in again +afterward. Do not reset data you want to keep, or reset between creating an export +and checking access to it. + +
+ +
+If using Docker: Reset the sample data + +This restores the Docker stack's sample records and clears sessions and exports. +Do not reset data you want to keep, or reset between creating an export and +checking access to it. Run: + +```bash +docker compose exec dataguard node --env-file=/run/config/app.env scripts/reset.ts +``` + +Sign in again afterward. + +
### Retry Amina's salary request @@ -661,24 +793,69 @@ CSRF header as in the first example and POST this body to } ``` -For the export lifecycle, sign in as Leah, run an allowed finance plan, and create -an export. Download it, inspect the fictional data, then revoke it. Download -must fail afterward. Exports also expire ten minutes after creation. +### Check access to a ready export, then revoke it + +Sign in as Leah, run an allowed finance plan, and create an export. Download it +and inspect the fictional data. Keep it ready while testing other users; +exports expire ten minutes after creation. + +
+Required step: Try Leah's ready export as another user + +In Leah's browser Network panel, find the `POST /api/exports` response and note +its `export.id` and `downloadRef` privately. Keep Leah's profile signed in. +In separate browser profiles, sign in as Amina and Theo at +`http://localhost:17005`. Run this in each authenticated app page's console, +using Leah's export values when prompted: + +```js +await (async () => { + const exportId = window.prompt("Leah's ready export ID"); + const downloadRef = window.prompt("Leah's ready export reference"); + if (!exportId || !downloadRef) return; + const session = await fetch("/api/session").then((response) => + response.json(), + ); + for (const [url, body] of [ + ["/api/exports/download", { downloadRef }], + [`/api/exports/${encodeURIComponent(exportId)}/revoke`, undefined], + ]) { + const response = await fetch(url, { + method: "POST", + headers: { + "content-type": "application/json", + "x-csrf-token": session.csrfToken, + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + const result = await response.json(); + console.log(response.status, result.error, result.requestId); + } +})(); +``` + +Both requests should return **403** with `authorization_denied`. The script +prints only status, error, and request ID. Never publish the reference, cookies, +or session credentials. If the download returns `410 export_expired` or +`410 export_revoked`, create another export as Leah and retry while it is ready; +that lifecycle response has not tested the other user's permission. A `404` +means the reference or ID was not found. + +
-Amina and Theo must not download or revoke Leah's export even with its reference -or ID. Use separate sessions or the automated test to distinguish a role denial -from a missing reference. The routes are -`POST /api/exports/download` with `{ "downloadRef": "" }` and -`POST /api/exports//revoke`. Both require that session's CSRF header and -same-origin request. Never publish actual session or download credentials. +Return to Leah's profile. Download the same export again to confirm the denied +attempts left it available, then revoke it. A further download request should +return **410** with `export_revoked`. To send it directly, use the console block +above in Leah's profile with only its `/api/exports/download` entry. The UI also +disables download after revocation. ### Read the query and export logs Let's use the logs to distinguish a permission decision from a completed query -or export. `authorization.context` connects the application request, actor, capability, -and preview/enforcement phase with a Cedarling request ID. Cedarling's decision -JSON includes full `diagnostics.reason` and `errors`. Field inspection has one -decision per field; seeing both ALLOW and DENY in that batch is normal. +or export. `authorization.context` connects the application request, actor, +capability, and preview/enforcement phase with a Cedarling request ID. Cedarling's +decision JSON includes full `diagnostics.reason` and `errors`. Field inspection +has one decision per field; seeing both ALLOW and DENY in that batch is normal. Amina's salary request can produce DENY with an empty reason and no errors: no permit matched. An allowed query or aggregate cites `authorized-plan`; an @@ -697,11 +874,39 @@ memory expire after five minutes, so they are not a lasting audit record. ### Check changed permissions and unavailable decisions -We've checked the visible results and logs. The automated suite also covers -changes while authorization is pending. Use a separate checkout of the -[finished project on main](https://github.com/GluuFederation/cedarling-tutorials/tree/main/p5-dataguard), -which includes the integration tests, and install its locked project and shared IdP dependencies. -Stop your learner stack to free ports 17005 and 18005, then run: +The manual exercises check permissions with stable data. We'll use the matching +integration tests to check changes while Cedarling is evaluating a request, in +the same checkout we've been building. + +
+Required step: Add the checks for this integration + +```bash +node ../shared/tools/step/run.mjs p5 checks +``` + +Updated files: + +- [`test/app.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/test/app.test.ts) checks API decisions, export ownership and lifecycle, stale facts, and failures. +- [`test/harness.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/test/harness.ts) supplies disposable data, sessions, and a real Cedarling instance for the tests. +- [`test/query.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/test/query.test.ts) checks query validation and SQL compilation. +- [`playwright.config.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/playwright.config.ts) connects the browser checks to their isolated application stack. +- Repository-level [`shared/dev-supervisor.mjs`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/shared/dev-supervisor.mjs) manages those processes and detects occupied test ports. + +New files: + +- [`test/authorization.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/test/authorization.test.ts) exercises real policies and decision logging. +- [`test/config.test.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/test/config.test.ts) checks configuration validation. +- [`e2e/sensitive-data-authorization.e2e.ts`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/p5-dataguard/e2e/sensitive-data-authorization.e2e.ts) checks sign-in, controls, and direct API access in a browser. +- Repository-level [`shared/browser-test-stack.mjs`](https://github.com/GluuFederation/cedarling-tutorials/blob/main/shared/browser-test-stack.mjs) builds and starts the disposable app and IdP used by Playwright. + +The step removes `e2e/sensitive-data-gaps.e2e.ts` and updates `test` +to build the policy archive before running Vitest. + +
+ +Stop the exercise stack to free ports 17005 and 18005, then run from this +checkout's `p5-dataguard/`: ```bash pnpm exec playwright install chromium @@ -718,27 +923,22 @@ returned group's minimum size, change permissions or counts while authorization is pending, and confirm that unavailable Cedarling releases no protected results or files. Browser checks use real sign-in and bypass the UI to call the API. -For a fresh exercise, `pnpm reset` clears the sample -records, exports, and sessions; sign in again. In Docker use -`docker compose exec dataguard node --env-file=/run/config/app.env scripts/reset.ts`. -Do not reset a database you want to keep. Capture the identical unauthorized -compensation request before and after, and Leah's allowed finance export. +Keep the before-and-after salary responses and Leah's allowed export as your +exercise evidence. The [finished P5 project](https://github.com/GluuFederation/cedarling-tutorials/tree/main/p5-dataguard) +is available if you need to compare a file or run the complete example separately.
-Warning: Before deploying this application - -Replace local HTTP and the bundled learning IdP with HTTPS and a configured -OIDC/OAuth issuer, such as [Jans Auth](https://docs.jans.io/stable/janssen-server/planning/use-cases/), -Gluu, Auth0, or Okta. +Warning: Learning project only -Control permission changes, secure stored data, and store audit logs. Keep -parameterized SQL, transactions, expiry, and file cleanup alongside authorization. -A five-record minimum is a teaching example: users can still learn sensitive -facts by combining repeated queries. +This project is for learning only and is not intended for production use. +Adapting it requires a separate security and operational review, including HTTPS, +a production OIDC provider, data storage, and audit logging. The five-record +minimum is a teaching rule; it does not prevent sensitive facts from being +inferred across repeated queries. -For a production stack, consider Agama Lab Policy Designer for policy authoring, -Jans Auth for issuing tokens, and Lock Server for centralized decision logs. -See [Cedarling production solutions](https://cedarling.dev/solutions). +For components to consider, see [Cedarling production solutions](https://cedarling.dev/solutions): +Agama Lab Policy Designer for policy authoring, Jans Auth for identity, and +Lock Server for centralized decision logs. These steps were prepared on Ubuntu 24.04+. Native project checks also run in CI on macOS and Windows. If a platform-specific step fails, @@ -746,7 +946,7 @@ on macOS and Windows. If a platform-specific step fails,
-## What we've learned +## Recap: queries and exports Amina's salary request now fails, while her support queries still work. We've given Theo bounded aggregates and kept Leah's finance reports and exports @@ -761,6 +961,6 @@ while waiting. We can use the same approach in a GraphQL API by enforcing field and data permissions in the resolvers or services that release them; see [GraphQL's authorization guidance](https://graphql.org/learn/authorization/). -[^1]: An aggregate reports a calculation over multiple records, such as a count by department, without returning the individual rows. Small groups and repeated queries can still reveal sensitive facts, so P5 treats aggregate release as its own authorization boundary. +[^1]: Small groups and repeated aggregate queries can reveal sensitive facts without returning individual rows. P5 checks aggregate release separately from row access; its minimum group size is not a complete privacy guarantee. [^2]: The pinned [`cedarling_wasm` JavaScript API](https://www.npmjs.com/package/@janssenproject/cedarling_wasm/v/0.0.468) accepts a JSON-string request. Converting to JSON does not validate the query plan; the server must do that before calling Cedarling. diff --git a/shared/tools/repository-automation/tutorial-contract.mjs b/shared/tools/repository-automation/tutorial-contract.mjs index 5e69035..648547b 100644 --- a/shared/tools/repository-automation/tutorial-contract.mjs +++ b/shared/tools/repository-automation/tutorial-contract.mjs @@ -41,6 +41,13 @@ const metadataSchema = z (value) => Date.parse(value) <= Date.now(), "must not be in the future", ), + lastUpdated: z.iso + .datetime({ offset: true }) + .refine( + (value) => Date.parse(value) <= Date.now(), + "must not be in the future", + ) + .optional(), }) .strict(); const safeAssetPathPattern = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/; @@ -81,6 +88,11 @@ function walkMarkdown(node, visit) { } function parseTutorial(markdown, sourcePath) { + if (markdown.includes("REPLACE_WITH_REVIEWED_COMMIT_SHA")) { + fail( + `${sourcePath}: replace the tutorial helper's draft source pin with its reviewed commit SHA before publication`, + ); + } const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/.exec(markdown); if (!match) fail(`${sourcePath}: expected a YAML frontmatter block`); @@ -282,10 +294,7 @@ function validateSvg(bytes, path) { } } -export async function validateTutorialProject( - repositoryRoot, - project, -) { +export async function validateTutorialProject(repositoryRoot, project) { if (!tutorialProjects.includes(project)) { fail(`unknown tutorial project: ${project}`); } diff --git a/shared/tools/repository-automation/tutorial-contract.test.mjs b/shared/tools/repository-automation/tutorial-contract.test.mjs index 130c839..25cc094 100644 --- a/shared/tools/repository-automation/tutorial-contract.test.mjs +++ b/shared/tools/repository-automation/tutorial-contract.test.mjs @@ -2,7 +2,14 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { + mkdtemp, + mkdir, + readdir, + readFile, + rm, + writeFile, +} from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import test from "node:test"; @@ -11,6 +18,7 @@ import sharp from "sharp"; import { validateTutorialProject } from "./tutorial-contract.mjs"; import { tutorialProjects } from "./changed-projects.mjs"; +import { validateManifest } from "../step/run.mjs"; const project = "p1-task-manager"; const validationScript = fileURLToPath( @@ -68,12 +76,64 @@ async function portfolioFixture(withTutorial = false) { return root; } +test("tutorial commands follow their registered step order", async () => { + const root = fileURLToPath(new URL("../../../", import.meta.url)); + const manifests = join(root, "shared/tools/step/projects"); + for (const file of await readdir(manifests)) { + if (!file.endsWith(".json")) continue; + const id = file.slice(0, -5); + const manifest = validateManifest( + JSON.parse(await readFile(join(manifests, file), "utf8")), + id, + ); + const tutorial = await readFile( + join(root, manifest.directory, "docs/tutorials.md"), + "utf8", + ); + const commands = [ + ...tutorial.matchAll( + /node \.\.\/shared\/tools\/step\/run\.mjs ([a-z0-9-]+) ([a-z0-9-]+)/g, + ), + ]; + assert.deepEqual( + commands.map((match) => [match[1], match[2]]), + ["init", ...manifest.steps.map((step) => step.id)].map((step) => [ + id, + step, + ]), + ); + } +}); + test("validates the current tutorial source contract", async (context) => { const root = await fixture(); context.after(() => rm(root, { force: true, recursive: true })); const validated = await validateTutorialProject(root, project); assert.deepEqual(validated.assets, ["boundary.svg", "social-card.webp"]); - assert.equal(validated.markdown, await readFile(join(root, project, "docs", "tutorials.md"), "utf8")); + assert.equal( + validated.markdown, + await readFile(join(root, project, "docs", "tutorials.md"), "utf8"), + ); +}); + +test("rejects an unresolved tutorial helper source pin", async (context) => { + const root = await fixture(); + context.after(() => rm(root, { force: true, recursive: true })); + const path = join(root, project, "docs", "tutorials.md"); + const markdown = await readFile(path, "utf8"); + await writeFile( + path, + `${markdown}\nnode run.mjs p1 init --source REPLACE_WITH_REVIEWED_COMMIT_SHA\n`, + ); + await assert.rejects( + validateTutorialProject(root, project), + /reviewed commit SHA before publication/, + ); + await writeFile( + path, + `${markdown}\nnode run.mjs p1 init --source ${"a".repeat(40)}\n`, + ); + await assert.doesNotReject(validateTutorialProject(root, project)); }); test("rejects a tutorial whose referenced asset is unavailable", async (context) => { @@ -87,6 +147,39 @@ test("rejects a tutorial whose referenced asset is unavailable", async (context) ); }); +test("accepts an optional article update date alongside verification", async (context) => { + const root = await fixture(); + context.after(() => rm(root, { force: true, recursive: true })); + const tutorialPath = join(root, project, "docs", "tutorials.md"); + const markdown = await readFile(tutorialPath, "utf8"); + await writeFile( + tutorialPath, + markdown.replace( + "order: 10", + "order: 10\nlastUpdated: 2026-09-02T12:00:00Z", + ), + ); + await assert.doesNotReject(validateTutorialProject(root, project)); +}); + +test("rejects invalid or future article update dates", async (context) => { + const root = await fixture(); + context.after(() => rm(root, { force: true, recursive: true })); + const tutorialPath = join(root, project, "docs", "tutorials.md"); + const markdown = await readFile(tutorialPath, "utf8"); + for (const date of [ + "2026-09-02", + "2026-99-99T12:00:00Z", + "2999-01-01T00:00:00Z", + ]) { + await writeFile( + tutorialPath, + markdown.replace("order: 10", `order: 10\nlastUpdated: ${date}`), + ); + await assert.rejects(validateTutorialProject(root, project), /lastUpdated/); + } +}); + test("rejects a missing or incorrectly sized frontmatter social card", async (context) => { const root = await fixture(); context.after(() => rm(root, { force: true, recursive: true })); diff --git a/shared/tools/step/README.md b/shared/tools/step/README.md new file mode 100644 index 0000000..beaf6cc --- /dev/null +++ b/shared/tools/step/README.md @@ -0,0 +1,43 @@ +# Step helper + +Copies a tutorial step's files from a pinned Git commit. Requires Node.js and Git. + +## Follow a tutorial + +Use a separate checkout for each tutorial. Run its setup once in a fresh baseline +checkout; setup replaces the helper directory. Then, from the repository root: + +```sh +node shared/tools/step/run.mjs p1 init --source +node shared/tools/step/run.mjs p1 policy-store --dry-run +node shared/tools/step/run.mjs p1 policy-store +``` + +Use the article's source SHA and follow its step order. From a project directory, +use `../shared/tools/step/run.mjs`. `--dry-run` previews changes; +`--yes` skips confirmation. Edited target files stop the step. + +## Recover an interrupted step + +Before writing, the helper saves affected files and a change journal in +`.local/step//recovery/`, ignored by Git. Printed paths are relative +to your current directory. A failed step can leave some files updated; resolve +the error and rerun that step to finish. Remove a reported lock file only after +confirming the helper has stopped. + +There is no undo command. Recovery copies cover helper file changes, not +database contents, Docker volumes, or dependency installations. Restoring files +manually also requires reconciling the helper's progress. + +## Add a project + +Use [projects/p1.json](projects/p1.json) as the manifest example. Declare the +baseline SHA and ordered `files`, `remove`, and package `scripts` changes. Each +file belongs to one step. Dependency installation stays in the tutorial commands. +Replay the steps in a separate checkout of that baseline before publishing. + +Run the helper tests from the repository root: + +```sh +node --test shared/tools/step/run.test.mjs +``` diff --git a/shared/tools/step/projects/p1.json b/shared/tools/step/projects/p1.json new file mode 100644 index 0000000..e129fb2 --- /dev/null +++ b/shared/tools/step/projects/p1.json @@ -0,0 +1,83 @@ +{ + "version": 1, + "id": "p1", + "directory": "p1-task-manager", + "baseline": "21b0832be4b31271320df992d04e9d97667d0e38", + "steps": [ + { + "id": "policy-store", + "files": [ + "p1-task-manager/policy-store/metadata.json", + "p1-task-manager/policy-store/schema.cedarschema", + "p1-task-manager/policy-store/trusted-issuers/tutorial-idp.json", + "p1-task-manager/policy-store/policies/server-access.cedar", + "p1-task-manager/policy-store/policies/browser-shadow-access.cedar" + ], + "remove": [], + "scripts": [] + }, + { + "id": "archive-builder", + "files": ["shared/policy-store.mjs", "shared/policy-store.d.mts"], + "remove": [], + "scripts": [] + }, + { + "id": "server", + "files": [ + "p1-task-manager/src/server/authorization-trace.ts", + "p1-task-manager/src/server/app.ts", + "p1-task-manager/src/server/main.ts", + "p1-task-manager/src/server/config.ts", + "p1-task-manager/tsconfig.server.json", + "p1-task-manager/src/shared/authorization.ts" + ], + "remove": ["p1-task-manager/src/server/capabilities.ts"], + "scripts": [] + }, + { + "id": "browser", + "files": [ + "p1-task-manager/src/web/authorization-trace.ts", + "p1-task-manager/src/web/api.ts", + "p1-task-manager/src/web/types.ts", + "p1-task-manager/src/web/App.tsx", + "p1-task-manager/tsconfig.web.json" + ], + "remove": [], + "scripts": [] + }, + { + "id": "startup", + "files": [ + "p1-task-manager/scripts/setup.mjs", + "p1-task-manager/scripts/dev.mjs", + "p1-task-manager/Dockerfile", + "shared/dev-supervisor.mjs" + ], + "remove": [], + "scripts": ["build", "dev"] + }, + { + "id": "checks", + "files": [ + "p1-task-manager/test/app.test.ts", + "p1-task-manager/test/setup.test.ts", + "p1-task-manager/test/web-app.test.tsx", + "p1-task-manager/test/policy-store.test.ts", + "p1-task-manager/test/browser-authorization.test.ts", + "p1-task-manager/test/config.test.ts", + "p1-task-manager/test/server-lifecycle.test.ts", + "p1-task-manager/e2e/task-authorization.e2e.ts", + "p1-task-manager/playwright.config.ts", + "shared/browser-test-stack.mjs", + "p1-task-manager/tsconfig.json", + "p1-task-manager/tsconfig.lint.json", + "p1-task-manager/eslint.config.js", + "p1-task-manager/.prettierignore" + ], + "remove": [], + "scripts": ["check", "test:e2e"] + } + ] +} diff --git a/shared/tools/step/projects/p2.json b/shared/tools/step/projects/p2.json new file mode 100644 index 0000000..7160954 --- /dev/null +++ b/shared/tools/step/projects/p2.json @@ -0,0 +1,87 @@ +{ + "version": 1, + "id": "p2", + "directory": "p2-tenantrag", + "baseline": "21b0832be4b31271320df992d04e9d97667d0e38", + "steps": [ + { + "id": "metadata", + "files": [ + "p2-tenantrag/src/rag/fixtures.ts", + "p2-tenantrag/src/rag/types.ts", + "p2-tenantrag/src/rag/pdf.ts", + "p2-tenantrag/src/rag/repository.ts", + "p2-tenantrag/src/rag/corpus.ts", + "p2-tenantrag/src/rag/setup.ts" + ], + "remove": [], + "scripts": [] + }, + { + "id": "policy-store", + "files": [ + "p2-tenantrag/policy-store/metadata.json", + "p2-tenantrag/policy-store/schema.cedarschema", + "p2-tenantrag/policy-store/policies/server-access.cedar", + "p2-tenantrag/policy-store/trusted-issuers/tutorial-idp.json" + ], + "remove": [], + "scripts": [] + }, + { + "id": "archive-builder", + "files": ["shared/policy-store.mjs", "shared/policy-store.d.mts"], + "remove": [], + "scripts": [] + }, + { + "id": "server", + "files": [ + "p2-tenantrag/src/authorization.ts", + "p2-tenantrag/src/runtime.ts", + "p2-tenantrag/src/rag/retrieval.ts", + "p2-tenantrag/src/rag/trace.ts", + "p2-tenantrag/src/app.ts", + "p2-tenantrag/src/main.ts", + "p2-tenantrag/src/config/project-config.ts", + "p2-tenantrag/src/openapi.ts", + "p2-tenantrag/src/auth/cli.ts", + "p2-tenantrag/tsconfig.build.json" + ], + "remove": [], + "scripts": [] + }, + { + "id": "startup", + "files": [ + "p2-tenantrag/scripts/prepare.ts", + "p2-tenantrag/scripts/preflight.ts", + "p2-tenantrag/scripts/dev.mjs", + "p2-tenantrag/scripts/setup.ts", + "p2-tenantrag/Dockerfile", + "p2-tenantrag/compose.yaml", + "shared/dev-supervisor.mjs" + ], + "remove": [], + "scripts": ["build", "dev"] + }, + { + "id": "checks", + "files": [ + "p2-tenantrag/test/app.test.ts", + "p2-tenantrag/test/auth-cli.test.ts", + "p2-tenantrag/test/config.test.ts", + "p2-tenantrag/test/corpus.test.ts", + "p2-tenantrag/test/retrieval.test.ts", + "p2-tenantrag/test/trace.test.ts", + "p2-tenantrag/scripts/test-e2e.ts", + "p2-tenantrag/eslint.config.js", + "p2-tenantrag/test/policy-store.test.ts", + "p2-tenantrag/test/setup.test.ts", + "p2-tenantrag/test/server-lifecycle.test.ts" + ], + "remove": [], + "scripts": [] + } + ] +} diff --git a/shared/tools/step/projects/p3.json b/shared/tools/step/projects/p3.json new file mode 100644 index 0000000..138d27c --- /dev/null +++ b/shared/tools/step/projects/p3.json @@ -0,0 +1,79 @@ +{ + "version": 1, + "id": "p3", + "directory": "p3-mcp-capability-governance", + "baseline": "21b0832be4b31271320df992d04e9d97667d0e38", + "steps": [ + { + "id": "mcp-client", + "files": ["p3-mcp-capability-governance/scripts/mcp-request.ts"], + "remove": [], + "scripts": [] + }, + { + "id": "policy-store", + "files": [ + "p3-mcp-capability-governance/policy-store/metadata.json", + "p3-mcp-capability-governance/policy-store/schema.cedarschema", + "p3-mcp-capability-governance/policy-store/policies/incident-operations.cedar", + "p3-mcp-capability-governance/policy-store/trusted-issuers/tutorial-idp.json" + ], + "remove": [], + "scripts": [] + }, + { + "id": "archive-builder", + "files": ["shared/policy-store.mjs", "shared/policy-store.d.mts"], + "remove": [], + "scripts": [] + }, + { + "id": "server", + "files": [ + "p3-mcp-capability-governance/src/mcp/authorization.ts", + "p3-mcp-capability-governance/src/mcp/server.ts", + "p3-mcp-capability-governance/src/mcp/availability.ts", + "p3-mcp-capability-governance/src/auth/token-verifier.ts", + "p3-mcp-capability-governance/src/incidents/types.ts", + "p3-mcp-capability-governance/src/incidents/repository.ts", + "p3-mcp-capability-governance/src/mcp/schemas.ts", + "p3-mcp-capability-governance/src/app.ts", + "p3-mcp-capability-governance/src/main.ts", + "p3-mcp-capability-governance/src/config/project-config.ts", + "p3-mcp-capability-governance/tsconfig.build.json" + ], + "remove": ["p3-mcp-capability-governance/src/mcp/trace.ts"], + "scripts": [] + }, + { + "id": "startup", + "files": [ + "p3-mcp-capability-governance/sidecar-bootstrap.json", + "p3-mcp-capability-governance/src/chat/host.ts", + "p3-mcp-capability-governance/scripts/setup.mjs", + "p3-mcp-capability-governance/Dockerfile", + "p3-mcp-capability-governance/compose.yaml" + ], + "remove": ["p3-mcp-capability-governance/scripts/dev.mjs"], + "scripts": ["build", "dev", "start"] + }, + { + "id": "checks", + "files": [ + "p3-mcp-capability-governance/eslint.config.js", + "p3-mcp-capability-governance/test/config.test.ts", + "p3-mcp-capability-governance/test/helpers.ts", + "p3-mcp-capability-governance/test/protocol.test.ts", + "p3-mcp-capability-governance/test/scenario.test.ts", + "p3-mcp-capability-governance/test/setup.test.ts", + "p3-mcp-capability-governance/test/authorization.test.ts", + "p3-mcp-capability-governance/test/policy-store.test.ts", + "p3-mcp-capability-governance/test/mcp-request.test.ts", + "p3-mcp-capability-governance/scripts/test-e2e.ts", + "p3-mcp-capability-governance/scripts/verify-stack.ts" + ], + "remove": [], + "scripts": ["test:e2e"] + } + ] +} diff --git a/shared/tools/step/projects/p4.json b/shared/tools/step/projects/p4.json new file mode 100644 index 0000000..005cb9c --- /dev/null +++ b/shared/tools/step/projects/p4.json @@ -0,0 +1,69 @@ +{ + "version": 1, + "id": "p4", + "directory": "p4-editorial-publishing", + "baseline": "21b0832be4b31271320df992d04e9d97667d0e38", + "steps": [ + { + "id": "policy-store", + "files": [ + "p4-editorial-publishing/policy-store/metadata.json", + "p4-editorial-publishing/policy-store/schema.cedarschema", + "p4-editorial-publishing/policy-store/policies/editorial.cedar" + ], + "remove": [], + "scripts": [] + }, + { + "id": "archive-builder", + "files": ["shared/policy-store.mjs", "shared/policy-store.d.mts"], + "remove": [], + "scripts": [] + }, + { + "id": "server", + "files": [ + "p4-editorial-publishing/src/server/authorization.ts", + "p4-editorial-publishing/src/server/models.ts", + "p4-editorial-publishing/src/server/errors.ts", + "p4-editorial-publishing/src/server/database.ts", + "p4-editorial-publishing/src/server/service.ts", + "p4-editorial-publishing/src/server/runtime.ts", + "p4-editorial-publishing/app/actions.ts", + "p4-editorial-publishing/app/articles/[articleId]/page.tsx", + "p4-editorial-publishing/next.config.ts", + "p4-editorial-publishing/app/action-button.tsx" + ], + "remove": [], + "scripts": [] + }, + { + "id": "startup", + "files": [ + "p4-editorial-publishing/scripts/setup.ts", + "p4-editorial-publishing/Dockerfile", + "p4-editorial-publishing/app/auth/callback/route.ts", + "p4-editorial-publishing/app/error.tsx", + "p4-editorial-publishing/app/styles.css" + ], + "remove": [], + "scripts": ["build"] + }, + { + "id": "checks", + "files": [ + "p4-editorial-publishing/test/authorization.test.ts", + "p4-editorial-publishing/test/database.test.ts", + "p4-editorial-publishing/test/service.test.ts", + "p4-editorial-publishing/test/support.ts", + "p4-editorial-publishing/playwright.config.ts", + "shared/dev-supervisor.mjs", + "p4-editorial-publishing/test/config.test.ts", + "p4-editorial-publishing/e2e/editorial-authorization.e2e.ts", + "shared/browser-test-stack.mjs" + ], + "remove": ["p4-editorial-publishing/e2e/editorial-gaps.e2e.ts"], + "scripts": ["test", "check"] + } + ] +} diff --git a/shared/tools/step/projects/p5.json b/shared/tools/step/projects/p5.json new file mode 100644 index 0000000..e0fc79f --- /dev/null +++ b/shared/tools/step/projects/p5.json @@ -0,0 +1,76 @@ +{ + "version": 1, + "id": "p5", + "directory": "p5-dataguard", + "baseline": "21b0832be4b31271320df992d04e9d97667d0e38", + "steps": [ + { + "id": "policy-store", + "files": [ + "p5-dataguard/policy-store/metadata.json", + "p5-dataguard/policy-store/schema.cedarschema", + "p5-dataguard/policy-store/policies/fields.cedar", + "p5-dataguard/policy-store/policies/plans.cedar", + "p5-dataguard/policy-store/policies/exports.cedar" + ], + "remove": [], + "scripts": [] + }, + { + "id": "archive-builder", + "files": ["shared/policy-store.mjs", "shared/policy-store.d.mts"], + "remove": [], + "scripts": [] + }, + { + "id": "server", + "files": [ + "p5-dataguard/src/server/authorization.ts", + "p5-dataguard/src/server/errors.ts", + "p5-dataguard/src/server/app.ts", + "p5-dataguard/src/server/database.ts", + "p5-dataguard/src/server/query.ts", + "p5-dataguard/src/server/export-service.ts", + "p5-dataguard/src/server/main.ts", + "p5-dataguard/src/server/config.ts", + "p5-dataguard/src/shared/contracts.ts", + "p5-dataguard/scripts/setup.ts" + ], + "remove": ["p5-dataguard/src/server/permissive-trace.ts"], + "scripts": [] + }, + { + "id": "browser", + "files": [ + "p5-dataguard/src/web/App.tsx", + "p5-dataguard/src/web/api.ts", + "p5-dataguard/src/web/Icon.tsx", + "p5-dataguard/src/web/styles.css" + ], + "remove": [], + "scripts": [] + }, + { + "id": "startup", + "files": ["p5-dataguard/scripts/reset.ts", "p5-dataguard/Dockerfile"], + "remove": [], + "scripts": ["build"] + }, + { + "id": "checks", + "files": [ + "p5-dataguard/test/app.test.ts", + "p5-dataguard/test/harness.ts", + "p5-dataguard/test/query.test.ts", + "p5-dataguard/playwright.config.ts", + "shared/dev-supervisor.mjs", + "p5-dataguard/test/authorization.test.ts", + "p5-dataguard/test/config.test.ts", + "p5-dataguard/e2e/sensitive-data-authorization.e2e.ts", + "shared/browser-test-stack.mjs" + ], + "remove": ["p5-dataguard/e2e/sensitive-data-gaps.e2e.ts"], + "scripts": ["test"] + } + ] +} diff --git a/shared/tools/step/run.mjs b/shared/tools/step/run.mjs new file mode 100644 index 0000000..243ca1d --- /dev/null +++ b/shared/tools/step/run.mjs @@ -0,0 +1,701 @@ +// Applies reviewed tutorial file groups without changing Git history or runtime data. +import { execFileSync } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { + lstat, + mkdir, + open, + readFile, + readdir, + realpath, + rename, + unlink, +} from "node:fs/promises"; +import { join, relative, sep } from "node:path"; +import { createInterface } from "node:readline/promises"; + +const toolPath = "shared/tools/step"; +const statePath = ".local/step"; +const commitPattern = /^[0-9a-f]{40}$/; +const idPattern = /^[a-z][a-z0-9-]*$/; +const maxFileBytes = 2 * 1024 * 1024; +const cancellationMessage = "Cancelled; no project files or progress changed."; +const hash = (bytes) => createHash("sha256").update(bytes).digest("hex"); +const equal = (a, b) => (a === null || b === null ? a === b : a.equals(b)); + +class LocalEditsError extends Error { + constructor(target) { + super(`Local edits in ${target}; no files changed.`); + this.name = "LocalEditsError"; + } +} + +class ConfirmationInterrupted extends Error { + constructor() { + super(cancellationMessage); + this.name = "ConfirmationInterrupted"; + } +} + +function requireThat(condition, message) { + if (!condition) throw new Error(message); +} + +function git(root, args) { + // check-ignore takes file names, not pathspecs, and rejects pathspec magic. + const literal = ["ls-tree", "ls-files"].includes(args[0]) + ? ["--literal-pathspecs"] + : []; + return execFileSync("git", [...literal, "-C", root, ...args], { + maxBuffer: 16 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); +} + +function object(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function parseJSON(bytes, label) { + try { + const value = JSON.parse(bytes.toString("utf8")); + requireThat(object(value), "Expected an object"); + return value; + } catch { + throw new Error(`Invalid JSON object: ${label}`); + } +} + +function validatePath(path) { + requireThat( + typeof path === "string" && path.length < 240, + "Invalid file path", + ); + for (const part of path.split("/")) { + requireThat( + /^[A-Za-z0-9_.\[\]-]+$/.test(part) && + part !== "." && + part !== ".." && + !part.endsWith(".") && + !/^(con|prn|aux|nul|com[0-9]|lpt[0-9])(?:\.|$)/i.test(part), + `Unsafe path: ${path}`, + ); + requireThat( + ![".git", ".local", ".data", "node_modules", "dist", "build"].includes( + part.toLowerCase(), + ) && !(part.toLowerCase().startsWith(".env") && part !== ".env.example"), + `Protected path: ${path}`, + ); + } + return path; +} + +export function validateManifest(value, project) { + requireThat( + object(value) && value.version === 1 && value.id === project, + "Invalid project manifest", + ); + requireThat( + commitPattern.test(value.baseline), + "Manifest requires a full baseline commit SHA", + ); + validatePath(value.directory); + requireThat( + !value.directory.includes("/") && + !value.directory.startsWith(".") && + value.directory !== "shared", + "Invalid project directory", + ); + requireThat( + Array.isArray(value.steps) && + value.steps.length > 0 && + value.steps.length <= 32, + "Invalid step list", + ); + const stepIds = new Set(); + const paths = new Set(); + const scripts = new Set(); + for (const step of value.steps) { + requireThat( + object(step) && + idPattern.test(step.id) && + step.id !== "init" && + !stepIds.has(step.id), + "Invalid or duplicate step ID", + ); + stepIds.add(step.id); + requireThat( + Object.keys(step).every((key) => + ["id", "files", "remove", "scripts"].includes(key), + ), + "Unknown step option", + ); + for (const operation of ["files", "remove", "scripts"]) { + requireThat( + Array.isArray(step[operation]) && step[operation].length <= 128, + `Invalid ${operation} list`, + ); + } + requireThat( + step.files.length + step.remove.length + step.scripts.length > 0, + "Empty step", + ); + for (const path of [...step.files, ...step.remove]) { + validatePath(path); + requireThat( + path.startsWith(`${value.directory}/`) || path.startsWith("shared/"), + `Path outside project/shared scope: ${path}`, + ); + requireThat( + !path.startsWith("shared/tools/") && + !path.endsWith("/package.json") && + !path.endsWith("/pnpm-lock.yaml"), + `Use declared script edits instead: ${path}`, + ); + requireThat( + !paths.has(path.toLowerCase()), + `File appears in multiple operations: ${path}`, + ); + paths.add(path.toLowerCase()); + } + for (const key of step.scripts) { + requireThat( + typeof key === "string" && + /^[a-z][a-z0-9:-]*$/.test(key) && + !["constructor", "prototype"].includes(key) && + !scripts.has(key), + "Invalid or repeated script entry", + ); + scripts.add(key); + } + } + requireThat( + Object.keys(value).every((key) => + ["version", "id", "directory", "baseline", "steps"].includes(key), + ), + "Unknown manifest option", + ); + return value; +} + +function requireCommit(root, sha) { + requireThat( + commitPattern.test(sha ?? ""), + "Use the full 40-character reviewed commit SHA, not a branch or placeholder", + ); + try { + requireThat( + git(root, ["cat-file", "-t", sha]).toString().trim() === "commit", + "Not a commit", + ); + } catch { + throw new Error( + `Commit ${sha} is unavailable. Run git fetch origin, then retry with the reviewed SHA.`, + ); + } +} + +function sourceFile(root, commit, path) { + const entry = git(root, ["ls-tree", "-z", commit, "--", path]).toString( + "utf8", + ); + if (!entry) return null; + const match = /^(100644|100755) blob ([0-9a-f]{40})\t([^\0]+)\0$/.exec(entry); + requireThat( + match && match[3] === path, + `Source must be a regular file: ${path}`, + ); + const size = Number(git(root, ["cat-file", "-s", match[2]]).toString()); + requireThat(size <= maxFileBytes, `Source file exceeds 2 MiB: ${path}`); + return { + bytes: git(root, ["cat-file", "blob", match[2]]), + mode: match[1] === "100755" ? 0o755 : 0o644, + }; +} + +// Check every existing component, including ignored state directories. +async function safePath(root, path, directory = false) { + const parts = path.split("/"); + let current = root; + for (let i = 0; i < parts.length; i += 1) { + current = join(current, parts[i]); + let info; + try { + info = await lstat(current); + } catch (error) { + if (error.code === "ENOENT") return; + throw error; + } + requireThat(!info.isSymbolicLink(), `Symlink refused: ${path}`); + if (i < parts.length - 1 || directory) + requireThat(info.isDirectory(), `Expected directory: ${path}`); + else requireThat(info.isFile(), `Expected regular file: ${path}`); + } +} + +async function currentFile(root, path) { + await safePath(root, path); + try { + const info = await lstat(join(root, path)); + requireThat(info.size <= maxFileBytes, `Local file exceeds 2 MiB: ${path}`); + return await readFile(join(root, path)); + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } +} + +async function makeDirectory(root, path) { + await safePath(root, path, true); + await mkdir(join(root, path), { recursive: true }); + await safePath(root, path, true); +} + +async function writeAtomic(root, path, bytes, mode = 0o600) { + const parent = path.split("/").slice(0, -1).join("/"); + if (parent) await makeDirectory(root, parent); + await safePath(root, path); + const target = join(root, path); + const temporary = `${target}.tutorial-${randomUUID()}.tmp`; + const file = await open(temporary, "wx", mode); + try { + await file.writeFile(bytes); + await file.close(); + await rename(temporary, target); + } finally { + await file.close(); + await unlink(temporary).catch((error) => { + if (error.code !== "ENOENT") throw error; + }); + } +} + +async function verifyTools(root, source) { + const entries = git(root, [ + "ls-tree", + "-r", + "--name-only", + "-z", + source, + "--", + toolPath, + ]) + .toString() + .split("\0") + .filter(Boolean); + requireThat( + entries.includes(`${toolPath}/run.mjs`), + "The source commit does not contain the tutorial helper", + ); + for (const path of entries) { + validatePath(path); + requireThat( + equal( + await currentFile(root, path), + sourceFile(root, source, path)?.bytes ?? null, + ), + `Helper differs from its pinned source: ${path}. Use a fresh checkout and the matching helper.`, + ); + } +} + +function packageScripts(bytes, label) { + const value = parseJSON(bytes, label); + requireThat(object(value.scripts), `Missing scripts object: ${label}`); + return value; +} + +async function prepare(root, manifest, source, step) { + const changes = []; + for (const [paths, removing] of [ + [step.files, false], + [step.remove, true], + ]) { + for (const path of paths) { + const baseline = sourceFile(root, manifest.baseline, path); + const target = sourceFile(root, source, path); + requireThat( + removing ? baseline && !target : target, + `Missing or inconsistent source operation: ${path}`, + ); + const before = await currentFile(root, path); + const after = removing ? null : target.bytes; + if (!equal(before, baseline?.bytes ?? null) && !equal(before, after)) + throw new LocalEditsError(path); + changes.push({ + path, + before, + after, + mode: target?.mode ?? baseline.mode, + }); + } + } + if (step.scripts.length) { + const path = `${manifest.directory}/package.json`; + const before = await currentFile(root, path); + requireThat(before !== null, `Missing ${path}`); + const current = packageScripts(before, path); + const baseline = packageScripts( + sourceFile(root, manifest.baseline, path)?.bytes, + path, + ); + const target = packageScripts(sourceFile(root, source, path)?.bytes, path); + requireThat( + current.name === baseline.name && target.name === baseline.name, + `Unexpected package name: ${path}`, + ); + let edited = false; + for (const key of step.scripts) { + requireThat( + Object.hasOwn(target.scripts, key) && + typeof target.scripts[key] === "string", + `Missing source script: ${key}`, + ); + const prior = Object.hasOwn(baseline.scripts, key) + ? baseline.scripts[key] + : undefined; + const actual = Object.hasOwn(current.scripts, key) + ? current.scripts[key] + : undefined; + if (actual !== prior && actual !== target.scripts[key]) + throw new LocalEditsError(`${path} scripts.${key}`); + if (actual !== target.scripts[key]) { + current.scripts[key] = target.scripts[key]; + edited = true; + } + } + changes.push({ + path, + before, + after: edited + ? Buffer.from(`${JSON.stringify(current, null, 2)}\n`) + : before, + mode: 0o644, + scripts: step.scripts, + }); + } + return changes; +} + +function parseArguments(args) { + const [project, step, ...flags] = args; + requireThat( + idPattern.test(project ?? "") && idPattern.test(step ?? ""), + "Usage: node run.mjs init --source | [--dry-run | --yes]", + ); + const options = { project, step, yes: false, dryRun: false }; + const seen = new Set(); + for (let i = 0; i < flags.length; i += 1) { + const flag = flags[i]; + requireThat(!seen.has(flag), `Repeated option: ${flag}`); + seen.add(flag); + if (flag === "--source" && step === "init") options.source = flags[++i]; + else if (flag === "--yes" && step !== "init") options.yes = true; + else if (flag === "--dry-run" && step !== "init") options.dryRun = true; + else throw new Error(`Unknown option: ${flag}`); + } + requireThat(!(options.yes && options.dryRun), "Choose --dry-run or --yes"); + return options; +} + +function validateState(state, project, manifest) { + requireThat( + object(state) && + state.version === 1 && + state.project === project && + commitPattern.test(state.source) && + state.baseline === manifest.baseline, + "Invalid tutorial state; keep its recovery files and use a fresh checkout", + ); + requireThat( + Array.isArray(state.completed) && + state.completed.every((id, index) => id === manifest.steps[index]?.id), + "Invalid step progress", + ); +} + +async function exclusive(root, cwd, action) { + await makeDirectory(root, statePath); + const path = `${statePath}/lock`; + await safePath(root, path); + let file; + try { + file = await open(join(root, path), "wx", 0o600); + } catch (error) { + if (error.code === "EEXIST") + throw new Error( + `Another tutorial step may be running. If an interrupted run left ${relative(cwd, join(root, path))}, stop that run before removing only that lock file.`, + ); + throw error; + } + try { + await file.writeFile(String(process.pid)); + return await action(); + } finally { + await file.close(); + await unlink(join(root, path)); + } +} + +async function confirmTerminal(message) { + requireThat( + process.stdin.isTTY && process.stdout.isTTY, + "Interactive confirmation requires a terminal; use --dry-run to inspect or --yes to apply", + ); + const terminal = createInterface({ + input: process.stdin, + output: process.stdout, + }); + const controller = new AbortController(); + const interrupt = () => controller.abort(); + terminal.once("SIGINT", interrupt); + process.once("SIGINT", interrupt); + try { + return /^y(?:es)?$/i.test( + ( + await terminal.question(`${message} [y/N] `, { + signal: controller.signal, + }) + ).trim(), + ); + } catch (error) { + if (controller.signal.aborted) throw new ConfirmationInterrupted(); + throw error; + } finally { + process.off("SIGINT", interrupt); + terminal.off("SIGINT", interrupt); + terminal.close(); + } +} + +export async function runTutorial( + args, + { + cwd = process.cwd(), + output = (line) => process.stdout.write(`${line}\n`), + confirm = confirmTerminal, + // Injected only by local tests to exercise filesystem failures; never read from manifests. + writeTarget = writeAtomic, + } = {}, +) { + cwd = await realpath(cwd); + const options = parseArguments(args); + const root = await realpath( + git(cwd, ["rev-parse", "--show-toplevel"]).toString().trim(), + ); + const path = `${toolPath}/projects/${options.project}.json`; + const bytes = await currentFile(root, path); + requireThat(bytes, `No tutorial steps are registered for ${options.project}`); + const manifest = validateManifest(parseJSON(bytes, path), options.project); + const working = relative(root, cwd).split(sep).join("/"); + requireThat( + working === "" || working === manifest.directory, + `Run this command from the repository root or ${manifest.directory}/`, + ); + const projectState = `${statePath}/${options.project}/state.json`; + await safePath(root, projectState); + try { + git(root, ["check-ignore", "--no-index", "--quiet", "--", projectState]); + requireThat( + !git(root, ["ls-files", "--", statePath]).length, + "Tracked helper state", + ); + } catch { + throw new Error( + "Tutorial state must be ignored and untracked. Use the tutorial's baseline checkout before initializing.", + ); + } + const savedBytes = await currentFile(root, projectState); + const state = savedBytes ? parseJSON(savedBytes, projectState) : null; + if (state) validateState(state, options.project, manifest); + const source = options.step === "init" ? options.source : state?.source; + if (options.step !== "init") + requireThat( + state, + `Initialize ${options.project} first with init --source `, + ); + requireCommit(root, source); + requireCommit(root, manifest.baseline); + await verifyTools(root, source); + requireThat( + equal(bytes, sourceFile(root, source, path)?.bytes ?? null), + "Project manifest is not registered in the pinned source", + ); + + const action = async () => { + // A second process must not apply a stale plan after the first releases its lock. + requireThat( + equal(await currentFile(root, projectState), savedBytes), + "Tutorial state changed; retry the command", + ); + await safePath(root, statePath, true); + let directories = []; + try { + directories = await readdir(join(root, statePath), { + withFileTypes: true, + }); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + for (const entry of directories) { + requireThat(!entry.isSymbolicLink(), "Symlink in tutorial state"); + if ( + entry.isDirectory() && + entry.name !== options.project && + (await currentFile(root, `${statePath}/${entry.name}/state.json`)) + ) { + throw new Error( + `This checkout belongs to ${entry.name}. Use a separate checkout for ${options.project}.`, + ); + } + } + if (options.step === "init") { + if (state) { + requireThat( + state.source === source, + "This project is pinned to another source; use a separate checkout for a different tutorial version", + ); + output(`${options.project} is already initialized at ${source}.`); + return; + } + // Check the declared starting files, including the files the integration will delete. + for (const step of manifest.steps) { + for (const change of await prepare(root, manifest, source, step)) { + if (!change.scripts) + requireThat( + equal( + change.before, + sourceFile(root, manifest.baseline, change.path)?.bytes ?? null, + ), + `Start from the declared baseline; ${change.path} is already changed`, + ); + } + } + await writeAtomic( + root, + projectState, + Buffer.from( + `${JSON.stringify({ version: 1, project: options.project, baseline: manifest.baseline, source, completed: [] }, null, 2)}\n`, + ), + ); + output( + `Initialized ${options.project} at source ${source}. Baseline: ${manifest.baseline}.`, + ); + return; + } + const index = manifest.steps.findIndex((step) => step.id === options.step); + requireThat( + index >= 0, + `Unknown step. Choose: ${manifest.steps.map((step) => step.id).join(", ")}`, + ); + if (index > state.completed.length) + throw new Error( + `Complete ${manifest.steps[state.completed.length].id} before ${options.step}`, + ); + let changes; + try { + changes = await prepare(root, manifest, source, manifest.steps[index]); + } catch (error) { + if (!(error instanceof LocalEditsError)) throw error; + const next = manifest.steps[state.completed.length]?.id; + const guidance = state.completed.includes(options.step) + ? `${options.step} is already complete. Your edits were preserved. ${next ? `If they are intentional, continue with ${next}.` : "All steps are complete; continue with the tutorial's verification commands."}` + : `Before applying ${options.step}, save a separate copy of your edits and reconcile the named target with the baseline or reviewed source, then retry.`; + throw new Error(`${error.message} ${guidance}`, { cause: error }); + } + const pending = changes.filter( + (change) => !equal(change.before, change.after), + ); + output(`${options.project} / ${options.step} — source ${source}`); + for (const change of changes) { + const label = equal(change.before, change.after) + ? "ready" + : change.after === null + ? "remove" + : change.before === null + ? "create" + : "replace"; + output( + ` ${label}: ${change.path}${change.scripts ? ` (scripts: ${change.scripts.join(", ")})` : ""}`, + ); + } + if (options.dryRun) { + output("Dry run; no files or progress changed."); + return; + } + if (!pending.length && state.completed.includes(options.step)) { + output("Already complete; no files changed."); + return; + } + if (!options.yes && !(await confirm(`Apply ${options.step}?`))) { + output(cancellationMessage); + return; + } + for (const change of changes) + requireThat( + equal(await currentFile(root, change.path), change.before), + `File changed during confirmation: ${change.path}; retry`, + ); + const recovery = `${statePath}/${options.project}/recovery/${randomUUID()}`; + const recoveryDisplay = relative(cwd, join(root, recovery)); + const journal = []; + for (let i = 0; i < pending.length; i += 1) { + const change = pending[i]; + const backup = change.before === null ? null : `${i}.before`; + if (backup) + await writeAtomic(root, `${recovery}/${backup}`, change.before); + journal.push({ + path: change.path, + backup, + before: change.before === null ? null : hash(change.before), + after: change.after === null ? null : hash(change.after), + }); + } + await writeAtomic( + root, + `${recovery}/changes.json`, + Buffer.from( + `${JSON.stringify({ project: options.project, step: options.step, source, files: journal }, null, 2)}\n`, + ), + ); + try { + for (const change of pending) { + requireThat( + equal(await currentFile(root, change.path), change.before), + `File changed before writing: ${change.path}`, + ); + if (change.after === null) await unlink(join(root, change.path)); + else await writeTarget(root, change.path, change.after, change.mode); + } + if (!state.completed.includes(options.step)) + state.completed.push(options.step); + await writeAtomic( + root, + projectState, + Buffer.from(`${JSON.stringify(state, null, 2)}\n`), + ); + } catch (error) { + throw new Error( + `Step interrupted: ${error.message}. Some files may already be updated. Recovery copies: ${recoveryDisplay}${sep}. Progress was not advanced; after resolving the error, rerun the same step to finish.`, + { cause: error }, + ); + } + output( + `Completed ${options.step}. Recovery copies: ${recoveryDisplay}${sep}. Continue with the tutorial's next command.`, + ); + }; + return options.dryRun ? action() : exclusive(root, cwd, action); +} + +if (import.meta.main) { + runTutorial(process.argv.slice(2)).catch((error) => { + if (error instanceof ConfirmationInterrupted) { + process.stdout.write(`${error.message}\n`); + process.exitCode = 130; + return; + } + process.stderr.write(`Tutorial step failed: ${error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/shared/tools/step/run.test.mjs b/shared/tools/step/run.test.mjs new file mode 100644 index 0000000..60690a1 --- /dev/null +++ b/shared/tools/step/run.test.mjs @@ -0,0 +1,710 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawn, spawnSync } from "node:child_process"; +import { + cp, + mkdir, + mkdtemp, + readFile, + readdir, + rename, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { runTutorial, validateManifest } from "./run.mjs"; + +const tools = dirname(fileURLToPath(import.meta.url)); +const toolPath = "shared/tools/step"; +const baselinePackage = { + name: "example", + scripts: { build: "old-build", check: "old-check", keep: "keep-me" }, + dependencies: { existing: "1.0.0" }, +}; +const json = (value) => `${JSON.stringify(value, null, 2)}\n`; + +test("registered project steps reference available source files and scripts", async () => { + const root = resolve(tools, "../../.."); + for (const file of await readdir(join(tools, "projects"))) { + if (!file.endsWith(".json")) continue; + const id = file.slice(0, -5); + const manifest = validateManifest( + JSON.parse(await readFile(join(tools, "projects", file), "utf8")), + id, + ); + const pkg = JSON.parse( + await readFile(join(root, manifest.directory, "package.json"), "utf8"), + ); + for (const step of manifest.steps) { + for (const path of step.files) { + assert.ok((await stat(join(root, path))).isFile(), path); + } + for (const key of step.scripts) + assert.equal(typeof pkg.scripts[key], "string"); + } + } +}); + +function git(root, ...args) { + return execFileSync("git", ["-C", root, ...args], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +async function put(root, path, value) { + await mkdir(dirname(join(root, path)), { recursive: true }); + await writeFile(join(root, path), value); +} + +async function fixture(context, mutate) { + const root = await mkdtemp(join(tmpdir(), "cedarling-step-")); + context.after(() => rm(root, { recursive: true, force: true })); + git(root, "init", "-b", "main"); + git(root, "config", "user.name", "Tutorial helper test"); + git(root, "config", "user.email", "test@example.invalid"); + git(root, "config", "core.autocrlf", "false"); + await put(root, ".gitignore", "**/.local/\n"); + await put(root, ".gitattributes", "* text=auto eol=lf\n"); + for (const directory of ["example-one", "another-project"]) { + await put(root, `${directory}/src/app.js`, "baseline\n"); + await put(root, `${directory}/src/obsolete.js`, "remove-me\n"); + await put(root, `${directory}/package.json`, json(baselinePackage)); + } + await put(root, "shared/helper.mjs", "baseline-shared\n"); + git(root, "add", "."); + git(root, "-c", "commit.gpgsign=false", "commit", "-m", "fixture baseline"); + const baseline = git(root, "rev-parse", "HEAD"); + await cp(tools, join(root, toolPath), { recursive: true }); + const manifests = []; + for (const [id, directory] of [ + ["p1", "example-one"], + ["future-project", "another-project"], + ]) { + const manifest = { + version: 1, + id, + directory, + baseline, + steps: [ + { + id: "server", + files: [ + `${directory}/src/new.js`, + `${directory}/src/app.js`, + "shared/helper.mjs", + ], + remove: [`${directory}/src/obsolete.js`], + scripts: [], + }, + { id: "checks", files: [], remove: [], scripts: ["build", "test:e2e"] }, + ], + }; + manifests.push(manifest); + await put(root, `${directory}/src/app.js`, "integrated\n"); + await put(root, `${directory}/src/new.js`, "new\n"); + await rm(join(root, directory, "src/obsolete.js")); + await put( + root, + `${directory}/package.json`, + json({ + ...baselinePackage, + scripts: { + ...baselinePackage.scripts, + build: "new-build", + "test:e2e": "new-test", + }, + }), + ); + await put(root, `${toolPath}/projects/${id}.json`, json(manifest)); + } + await put(root, "shared/helper.mjs", "integrated-shared\n"); + if (mutate) await mutate(root, manifests); + git(root, "add", "."); + git( + root, + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "fixture integration", + ); + const source = git(root, "rev-parse", "HEAD"); + git(root, "switch", "--detach", baseline); + git(root, "restore", `--source=${source}`, "--worktree", "--", toolPath); + const output = []; + const call = (args, options = {}) => + runTutorial(args, { + cwd: root, + output: (line) => output.push(line), + confirm: async () => true, + ...options, + }); + const read = (path) => readFile(join(root, path), "utf8"); + const state = async () => JSON.parse(await read(".local/step/p1/state.json")); + const init = () => call(["p1", "init", "--source", source]); + return { root, baseline, source, manifests, call, read, state, init, output }; +} + +function confirmCli(f, answer) { + // Exercise readline's terminal handling over pipes, including its Ctrl+C event. + const terminal = "process.stdin.isTTY = true; process.stdout.isTTY = true;"; + const child = spawn( + process.execPath, + [ + "--import", + `data:text/javascript,${encodeURIComponent(terminal)}`, + join(f.root, toolPath, "run.mjs"), + "p1", + "server", + ], + { cwd: join(f.root, "example-one"), stdio: ["pipe", "pipe", "pipe"] }, + ); + let stdout = ""; + let stderr = ""; + let answered = false; + child.stdout.on("data", (bytes) => { + stdout += bytes; + if (!answered && stdout.includes("[y/N]")) { + answered = true; + child.stdin.write(answer); + } + }); + child.stderr.on("data", (bytes) => { + stderr += bytes; + }); + return new Promise((done, reject) => { + const timeout = setTimeout(() => { + child.kill(); + reject(new Error("CLI confirmation did not finish")); + }, 10_000); + child.once("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + child.once("close", (code, signal) => { + clearTimeout(timeout); + done({ code, signal, stdout, stderr, answered }); + }); + }); +} + +test("initializes a source pin without changing project files, Git HEAD, or the index", async (t) => { + const f = await fixture(t); + const index = git(f.root, "diff", "--cached"); + await f.init(); + assert.equal(await f.read("example-one/src/app.js"), "baseline\n"); + assert.deepEqual((await f.state()).completed, []); + assert.equal((await f.state()).source, f.source); + assert.equal(git(f.root, "rev-parse", "HEAD"), f.baseline); + assert.equal(git(f.root, "diff", "--cached"), index); + assert.equal( + git(f.root, "check-ignore", ".local/step/p1/state.json"), + ".local/step/p1/state.json", + ); + await f.init(); + assert.match(f.output.at(-1), /already initialized/); +}); + +test("creates, replaces, and removes exactly the named files and preserves package settings", async (t) => { + const f = await fixture(t); + await f.init(); + await f.call(["p1", "server", "--yes"]); + assert.equal(await f.read("example-one/src/app.js"), "integrated\n"); + assert.equal(await f.read("example-one/src/new.js"), "new\n"); + await assert.rejects(f.read("example-one/src/obsolete.js"), { + code: "ENOENT", + }); + assert.equal(await f.read("another-project/src/app.js"), "baseline\n"); + const custom = JSON.parse(await f.read("example-one/package.json")); + custom.dependencies.added = "2.0.0"; + custom.scripts.custom = "my-command"; + await put(f.root, "example-one/package.json", json(custom)); + await f.call(["p1", "checks", "--yes"]); + const changed = JSON.parse(await f.read("example-one/package.json")); + assert.deepEqual(changed, { + ...custom, + scripts: { ...custom.scripts, build: "new-build", "test:e2e": "new-test" }, + }); + assert.deepEqual((await f.state()).completed, ["server", "checks"]); +}); + +test("dry run leaves project files, progress, and recovery directories unchanged", async (t) => { + const f = await fixture(t); + await f.init(); + const before = await f.read(".local/step/p1/state.json"); + await f.call(["p1", "server", "--dry-run"]); + assert.equal(await f.read("example-one/src/app.js"), "baseline\n"); + assert.equal(await f.read(".local/step/p1/state.json"), before); + assert.deepEqual(await readdir(join(f.root, ".local/step/p1")), [ + "state.json", + ]); + assert.match(f.output.at(-1), /Dry run/); +}); + +test("cancellation and missing prerequisites leave the application untouched", async (t) => { + const f = await fixture(t); + await f.init(); + await assert.rejects(f.call(["p1", "checks", "--yes"]), /Complete server/); + await f.call(["p1", "server"], { confirm: async () => false }); + assert.equal( + f.output.at(-1), + "Cancelled; no project files or progress changed.", + ); + assert.equal(await f.read("example-one/src/app.js"), "baseline\n"); + assert.deepEqual((await f.state()).completed, []); +}); + +test("repeating a completed step does not rewrite files or progress", async (t) => { + const f = await fixture(t); + await f.init(); + await f.call(["p1", "server", "--yes"]); + const before = await stat(join(f.root, "example-one/src/app.js")); + const state = await f.read(".local/step/p1/state.json"); + await f.call(["p1", "server", "--yes"]); + assert.equal( + (await stat(join(f.root, "example-one/src/app.js"))).mtimeMs, + before.mtimeMs, + ); + assert.equal(await f.read(".local/step/p1/state.json"), state); + assert.match(f.output.at(-1), /Already complete/); + await f.call(["p1", "checks", "--yes"]); + const complete = await f.read(".local/step/p1/state.json"); + await f.call(["p1", "server", "--yes"]); + await f.call(["p1", "checks", "--yes"]); + assert.equal(await f.read(".local/step/p1/state.json"), complete); + assert.match(f.output.at(-1), /Already complete/); +}); + +test("edited targets are refused before creating any other target", async (t) => { + const f = await fixture(t); + await f.init(); + await put(f.root, "example-one/src/app.js", "learner work\n"); + await assert.rejects( + f.call(["p1", "server", "--yes"]), + /Local edits in example-one\/src\/app.js; no files changed.*Before applying server.*save a separate copy.*reconcile/, + ); + assert.equal(await f.read("example-one/src/app.js"), "learner work\n"); + await assert.rejects(f.read("example-one/src/new.js"), { code: "ENOENT" }); +}); + +test("edited completed steps stay protected while later steps preserve the edits", async (t) => { + const f = await fixture(t); + await f.init(); + await f.call(["p1", "server", "--yes"]); + const before = await f.read(".local/step/p1/state.json"); + await put(f.root, "example-one/src/app.js", "learner experiment\n"); + for (const flag of ["--dry-run", "--yes"]) { + await assert.rejects( + f.call(["p1", "server", flag]), + /server is already complete\. Your edits were preserved\. If they are intentional, continue with checks\./, + ); + } + const result = spawnSync( + process.execPath, + [join(f.root, toolPath, "run.mjs"), "p1", "server", "--yes"], + { cwd: f.root, encoding: "utf8" }, + ); + assert.equal(result.status, 1); + assert.equal(await f.read(".local/step/p1/state.json"), before); + assert.equal(await f.read("example-one/src/app.js"), "learner experiment\n"); + await f.call(["p1", "checks", "--yes"]); + assert.equal(await f.read("example-one/src/app.js"), "learner experiment\n"); + assert.deepEqual((await f.state()).completed, ["server", "checks"]); + + const packageJson = JSON.parse(await f.read("example-one/package.json")); + packageJson.scripts.build = "learner-build"; + await put(f.root, "example-one/package.json", json(packageJson)); + await assert.rejects( + f.call(["p1", "checks", "--yes"]), + /scripts.build; no files changed.*checks is already complete.*All steps are complete/, + ); + assert.equal(await f.read("example-one/package.json"), json(packageJson)); +}); + +test("rechecks targets after confirmation", async (t) => { + const f = await fixture(t); + await f.init(); + await assert.rejects( + f.call(["p1", "server"], { + confirm: async () => { + await put( + f.root, + "example-one/src/app.js", + "edited while confirming\n", + ); + return true; + }, + }), + /changed during confirmation/, + ); + await assert.rejects(f.read("example-one/src/new.js"), { code: "ENOENT" }); +}); + +test("an altered package-script entry is refused without replacing dependencies", async (t) => { + const f = await fixture(t); + await f.init(); + await f.call(["p1", "server", "--yes"]); + const custom = structuredClone(baselinePackage); + custom.scripts.build = "learner-build"; + await put(f.root, "example-one/package.json", json(custom)); + await assert.rejects( + f.call(["p1", "checks", "--yes"]), + /scripts.build; no files changed.*Before applying checks/, + ); + assert.equal(await f.read("example-one/package.json"), json(custom)); +}); + +test("recovery and lock paths resolve from the caller's directory", async (t) => { + for (const directory of ["", "example-one"]) { + const f = await fixture(t); + const cwd = join(f.root, directory); + await f.init(); + const lock = ".local/step/lock"; + await put(f.root, lock, "stale test lock"); + await assert.rejects( + f.call(["p1", "server", "--yes"], { cwd }), + (error) => { + assert.ok( + error.message.includes(`left ${relative(cwd, join(f.root, lock))},`), + ); + return true; + }, + ); + await rm(join(f.root, lock)); + let writes = 0; + let failurePath; + await assert.rejects( + f.call(["p1", "server", "--yes"], { + cwd, + writeTarget: async (root, path, bytes) => { + if (++writes === 2) throw new Error("simulated disk failure"); + await put(root, path, bytes); + }, + }), + (error) => { + failurePath = error.message.match( + /Recovery copies: (.+)\. Progress/, + )[1]; + return true; + }, + ); + assert.equal( + (await stat(join(resolve(cwd, failurePath), "changes.json"))).isFile(), + true, + ); + await f.call(["p1", "server", "--yes"], { cwd }); + const successPath = f.output + .at(-1) + .match(/Recovery copies: (.+)\. Continue/)[1]; + assert.equal( + (await stat(join(resolve(cwd, successPath), "changes.json"))).isFile(), + true, + ); + const prefix = directory ? `..${sep}.local${sep}` : `.local${sep}`; + assert.ok(failurePath.startsWith(prefix)); + assert.ok(successPath.startsWith(prefix)); + } +}); + +test("CLI cancellation has clear output and releases the lock for retry", async (t) => { + for (const [answer, status] of [ + ["n\n", 0], + ["\u0003", 130], + ]) { + const f = await fixture(t); + await f.init(); + const before = await f.read(".local/step/p1/state.json"); + const result = await confirmCli(f, answer); + assert.equal(result.answered, true); + assert.equal(result.code, status, result.stderr); + assert.equal(result.signal, null); + assert.match( + result.stdout, + /Cancelled; no project files or progress changed\./, + ); + assert.equal(result.stderr, ""); + assert.doesNotMatch(result.stdout, /Tutorial step failed/); + assert.equal(await f.read("example-one/src/app.js"), "baseline\n"); + assert.equal(await f.read(".local/step/p1/state.json"), before); + await assert.rejects(f.read(".local/step/lock"), { + code: "ENOENT", + }); + assert.deepEqual(await readdir(join(f.root, ".local/step/p1")), [ + "state.json", + ]); + await f.call(["p1", "server", "--yes"]); + assert.deepEqual((await f.state()).completed, ["server"]); + } +}); + +test("write failure retains recovery copies and rerunning finishes the step", async (t) => { + const f = await fixture(t); + await f.init(); + let writes = 0; + await assert.rejects( + f.call(["p1", "server", "--yes"], { + writeTarget: async (root, path, bytes) => { + if (++writes === 2) throw new Error("simulated disk failure"); + await put(root, path, bytes); + }, + }), + /Some files may already be updated\. Recovery copies:.*Progress was not advanced/, + ); + assert.deepEqual((await f.state()).completed, []); + const attempts = await readdir(join(f.root, ".local/step/p1/recovery")); + const recovery = `.local/step/p1/recovery/${attempts[0]}`; + const journal = JSON.parse(await f.read(`${recovery}/changes.json`)); + const app = journal.files.find((item) => item.path.endsWith("/app.js")); + assert.equal(await f.read(`${recovery}/${app.backup}`), "baseline\n"); + await f.call(["p1", "server", "--yes"]); + assert.equal(await f.read("example-one/src/app.js"), "integrated\n"); + assert.deepEqual((await f.state()).completed, ["server"]); +}); + +test("requires a declared baseline and all source files before initialization", async (t) => { + const f = await fixture(t); + await put(f.root, "example-one/src/app.js", "different baseline\n"); + await assert.rejects(f.init(), /Local edits/); + await assert.rejects(f.state(), { code: "ENOENT" }); + const missing = await fixture(t, async (root, manifests) => { + manifests[0].steps[0].files.push("example-one/src/missing.js"); + await put(root, `${toolPath}/projects/p1.json`, json(manifests[0])); + }); + await assert.rejects(missing.init(), /Missing or inconsistent source/); + assert.equal(await missing.read("example-one/src/app.js"), "baseline\n"); +}); + +test("requires full available commit SHAs and a matching helper installation", async (t) => { + const f = await fixture(t); + await assert.rejects( + f.call(["p1", "init", "--source", "main"]), + /40-character/, + ); + await assert.rejects( + f.call(["p1", "init", "--source", "f".repeat(40)]), + /git fetch origin/, + ); + await put(f.root, `${toolPath}/run.mjs`, "modified helper\n"); + await assert.rejects(f.init(), /Helper differs/); +}); + +test("another project uses its own manifest and state in a separate checkout", async (t) => { + const first = await fixture(t); + await first.init(); + await assert.rejects( + first.call(["future-project", "init", "--source", first.source]), + /separate checkout/, + ); + const second = await fixture(t); + await second.call(["future-project", "init", "--source", second.source]); + await second.call(["future-project", "server", "--yes"], { + cwd: join(second.root, "another-project"), + }); + assert.equal(await second.read("another-project/src/app.js"), "integrated\n"); + assert.equal(await second.read("example-one/src/app.js"), "baseline\n"); + assert.equal( + JSON.parse(await second.read(".local/step/future-project/state.json")) + .project, + "future-project", + ); + assert.deepEqual((await first.state()).completed, []); +}); + +test("runs from the project directory but refuses another project directory", async (t) => { + const f = await fixture(t); + await f.call(["p1", "init", "--source", f.source], { + cwd: join(f.root, "example-one"), + }); + await assert.rejects( + f.call(["p1", "server", "--yes"], { cwd: join(f.root, "another-project") }), + /Run this command/, + ); +}); + +test("rejects unsafe or cross-project manifests and executable options", async (t) => { + const f = await fixture(t); + for (const path of [ + "../outside", + "/tmp/outside", + "example-one/../outside", + "example-one/.env", + "example-one/.data/db", + "example-one/src/NUL.txt", + "example-one/src/file:ads", + "example-one/src/*.js", + "example-one/src/file?.js", + "another-project/src/app.js", + `${toolPath}/run.mjs`, + "example-one/package.json", + ]) { + const value = structuredClone(f.manifests[0]); + value.steps[0].files[0] = path; + assert.throws(() => validateManifest(value, "p1")); + } + const value = structuredClone(f.manifests[0]); + value.steps[0].command = "do-not-execute"; + assert.throws(() => validateManifest(value, "p1"), /Unknown step option/); +}); + +test("copies a bracketed route literally without changing a sibling", async (t) => { + const route = "example-one/app/articles/[articleId]/page.tsx"; + const sibling = "example-one/app/articles/a/page.tsx"; + const f = await fixture(t, async (root, manifests) => { + manifests[0].steps[0].files.push(route); + await put(root, route, "reviewed route\n"); + await put(root, sibling, "source sibling\n"); + await put(root, `${toolPath}/projects/p1.json`, json(manifests[0])); + }); + await put(f.root, sibling, "learner sibling\n"); + await f.init(); + await f.call(["p1", "server", "--yes"]); + assert.equal(await f.read(route), "reviewed route\n"); + assert.equal(await f.read(sibling), "learner sibling\n"); + await f.call(["p1", "server", "--yes"]); + assert.equal(await f.read(route), "reviewed route\n"); + assert.equal(git(f.root, "rev-parse", "HEAD"), f.baseline); + assert.equal(git(f.root, "diff", "--cached"), ""); +}); + +test("rejects symlinked destination parents, including Windows junctions", async (t) => { + const f = await fixture(t); + await f.init(); + await rename( + join(f.root, "example-one/src"), + join(f.root, "example-one/saved-src"), + ); + await symlink( + join(f.root, "example-one/saved-src"), + join(f.root, "example-one/src"), + process.platform === "win32" ? "junction" : "dir", + ); + await assert.rejects(f.call(["p1", "server", "--yes"]), /Symlink refused/); + assert.equal(await f.read("example-one/saved-src/app.js"), "baseline\n"); +}); + +test("refuses corrupt progress and concurrent use", async (t) => { + const f = await fixture(t); + await f.init(); + await put(f.root, ".local/step/lock", "test-lock"); + await assert.rejects( + f.call(["p1", "server", "--yes"]), + /Another tutorial step/, + ); + await rm(join(f.root, ".local/step/lock")); + const state = await f.state(); + state.completed = ["checks"]; + await put(f.root, ".local/step/p1/state.json", json(state)); + await assert.rejects( + f.call(["p1", "server", "--yes"]), + /Invalid step progress/, + ); +}); + +test("rejects an extra local manifest absent from the pinned source", async (t) => { + const f = await fixture(t); + const extra = { ...f.manifests[0], id: "local-project" }; + await put(f.root, `${toolPath}/projects/local-project.json`, json(extra)); + await assert.rejects( + f.call(["local-project", "init", "--source", f.source]), + /not registered in the pinned source/, + ); +}); + +test("refuses changing the source pin after initialization", async (t) => { + const f = await fixture(t); + await f.init(); + const otherSource = git( + f.root, + "-c", + "commit.gpgsign=false", + "commit-tree", + `${f.source}^{tree}`, + "-p", + f.source, + "-m", + "another source", + ); + await assert.rejects( + f.call(["p1", "init", "--source", otherSource]), + /pinned to another source/, + ); +}); + +test("refuses a symlinked state directory", async (t) => { + const f = await fixture(t); + await f.init(); + await rename(join(f.root, ".local/step"), join(f.root, ".local/saved-state")); + await symlink( + join(f.root, ".local/saved-state"), + join(f.root, ".local/step"), + process.platform === "win32" ? "junction" : "dir", + ); + await assert.rejects(f.call(["p1", "server", "--yes"]), /Symlink refused/); +}); + +test("requires ignored, untracked state before initialization", async (t) => { + const f = await fixture(t); + await put(f.root, ".gitignore", ""); + await assert.rejects(f.init(), /ignored and untracked/); + await put(f.root, ".gitignore", "**/.local/\n"); + await put(f.root, ".local/step/tracked.json", "{}\n"); + git(f.root, "add", "--force", ".local/step/tracked.json"); + await assert.rejects(f.init(), /ignored and untracked/); +}); + +test("CLI initializes and applies steps through a symlinked checkout path", async (t) => { + const f = await fixture(t); + const directory = await mkdtemp(join(tmpdir(), "cedarling-step-alias-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const alias = join(directory, "checkout"); + await symlink( + f.root, + alias, + process.platform === "win32" ? "junction" : "dir", + ); + const cli = join(alias, toolPath, "run.mjs"); + const options = { cwd: join(alias, "example-one"), encoding: "utf8" }; + + const initialized = spawnSync( + process.execPath, + [cli, "p1", "init", "--source", f.source], + options, + ); + assert.equal(initialized.status, 0, initialized.stderr); + assert.deepEqual((await f.state()).completed, []); + + const applied = spawnSync( + process.execPath, + [cli, "p1", "server", "--yes"], + options, + ); + assert.equal(applied.status, 0, applied.stderr); + assert.match(applied.stdout, /Completed server/); + assert.equal(await f.read("example-one/src/app.js"), "integrated\n"); + assert.deepEqual((await f.state()).completed, ["server"]); + assert.equal(git(f.root, "rev-parse", "HEAD"), f.baseline); +}); + +test("CLI requires explicit confirmation in non-interactive use and handles unknown arguments", async (t) => { + const f = await fixture(t); + await f.init(); + const cli = join(f.root, toolPath, "run.mjs"); + const result = spawnSync(process.execPath, [cli, "p1", "server"], { + cwd: f.root, + encoding: "utf8", + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /Interactive confirmation/); + assert.equal(await f.read("example-one/src/app.js"), "baseline\n"); + await assert.rejects(f.call(["p1", "server", "--force"]), /Unknown option/); + await assert.rejects(f.call(["p1", "unknown", "--yes"]), /Unknown step/); + await assert.rejects( + f.call(["p1", "server", "--yes", "--dry-run"]), + /Choose/, + ); +});