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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 17 additions & 5 deletions p1-task-manager/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
716 changes: 501 additions & 215 deletions p1-task-manager/docs/tutorials.md

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions p1-task-manager/scripts/dev.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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({
Expand Down Expand Up @@ -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: {
Expand Down
2 changes: 1 addition & 1 deletion p1-task-manager/src/server/authorization-trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
42 changes: 38 additions & 4 deletions p1-task-manager/src/server/main.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -48,9 +48,43 @@ try {
}
console.log(`P1 Task Manager listening at ${config.baseUrl}`);

let stopping = false;

async function shutDown(signal: string): Promise<void> {
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<void>((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"));
13 changes: 12 additions & 1 deletion p1-task-manager/src/web/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ function Workspace({
const [detailExpiresAt, setDetailExpiresAt] = useState<number>();
const createTriggerRef = useRef<HTMLButtonElement>(null);
const deleteTriggerRef = useRef<HTMLButtonElement>(null);
const listRequestRef = useRef(0);

const replaceTask = useCallback((task: Task) => {
setTasks((current) => {
Expand All @@ -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(
Expand All @@ -242,6 +248,7 @@ function Workspace({
);
setListState("ready");
} catch (error) {
if (!isCurrent()) return;
if (error instanceof ApiError && error.status === 401) {
onSessionExpired();
return;
Expand All @@ -253,6 +260,9 @@ function Workspace({

useEffect(() => {
void loadTasks();
return () => {
listRequestRef.current += 1;
};
}, [loadTasks]);

useEffect(() => {
Expand Down Expand Up @@ -414,6 +424,7 @@ function Workspace({
event: SyntheticEvent<HTMLFormElement, SubmitEvent>,
) {
event.preventDefault();
if (!createAllowed) return;
setBusy(true);
setCreateError("");
try {
Expand Down Expand Up @@ -819,7 +830,7 @@ function Workspace({
>
Cancel
</button>
<button className="primary" disabled={busy}>
<button className="primary" disabled={busy || !createAllowed}>
Create task
</button>
</div>
Expand Down
6 changes: 6 additions & 0 deletions p1-task-manager/src/web/authorization-trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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",
);
Expand Down
39 changes: 38 additions & 1 deletion p1-task-manager/test/browser-authorization.test.ts
Original file line number Diff line number Diff line change
@@ -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() }));
Expand Down Expand Up @@ -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 = {
Expand All @@ -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(),
Expand Down
31 changes: 31 additions & 0 deletions p1-task-manager/test/policy-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof createServerAuthorization>> | 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(() => {});
Expand Down
Loading
Loading