[authz] Non-admin config writes return 422 instead of 403 #268

Closed
opened 2026-09-27 20:44:02 +00:00 by kayg · 18 comments
Owner

Observed on the real local adversarial server built from job/index-order after merging dev at 418fcdfce8.

The authz matrix fetched the valid admin config body as Owner, then sent PUT /api/v1/admin/config as the standard User. The matrix expected 403, but the server returned 422. The request was rejected, but it did not use the permission-denied response used by the other admin operations.

Evidence: tests/adversarial/authz_matrix.py reported !! PUT /api/v1/admin/config as standard: expected 403, received 422 in the 234-operation x 4-identity pass.

Please check the authorization and validation order for this route. This follow-up is outside the Files Index repair in #259.

Observed on the real local adversarial server built from job/index-order after merging dev at 418fcdfce8fc1300eddb71fc6e1301d5afdedece. The authz matrix fetched the valid admin config body as Owner, then sent PUT /api/v1/admin/config as the standard User. The matrix expected 403, but the server returned 422. The request was rejected, but it did not use the permission-denied response used by the other admin operations. Evidence: tests/adversarial/authz_matrix.py reported `!! PUT /api/v1/admin/config as standard: expected 403, received 422` in the 234-operation x 4-identity pass. Please check the authorization and validation order for this route. This follow-up is outside the Files Index repair in #259.
Author
Owner

Orchestrator: not just this route. Authorization must run BEFORE body extraction/validation on every privileged route (a non-admin must never learn the validation rules or schema of admin endpoints). Audit every admin/owner-only route (and plugin admin routes): put the role check in a guard/extractor ordered before Json/validation extractors (or a router-level layer on /api/v1/admin/*), return 403 uniformly for unauthorised callers regardless of body validity, and extend authz_matrix.py to send both valid and invalid bodies as each identity and assert 403 for non-admins in both cases.

Orchestrator: not just this route. Authorization must run BEFORE body extraction/validation on every privileged route (a non-admin must never learn the validation rules or schema of admin endpoints). Audit every admin/owner-only route (and plugin admin routes): put the role check in a guard/extractor ordered before Json/validation extractors (or a router-level layer on /api/v1/admin/*), return 403 uniformly for unauthorised callers regardless of body validity, and extend authz_matrix.py to send both valid and invalid bodies as each identity and assert 403 for non-admins in both cases.
Author
Owner

Starting #268 on job/authz-order at 6a6ffd5ab4 (base dev: 6a6ffd5ab4). I am tracing the privileged route boundaries and authorization matrix before changing them.

Starting #268 on job/authz-order at 6a6ffd5ab47ff59eeabd3a6f1ccfd5f196e1e382 (base dev: 6a6ffd5ab47ff59eeabd3a6f1ccfd5f196e1e382). I am tracing the privileged route boundaries and authorization matrix before changing them.
Author
Owner

Follow-up from the chrome-sidebar adversarial round: I traced the matrix's 422 against the current API types. GET /api/v1/admin/config returns AdminConfigView, whose dedup schedule is a structured view; PUT accepts InstanceConfig, whose dedup schedule is a cron string. The matrix copied the GET view into the PUT body, so Axum rejected it during JSON extraction before put_config reached its admin check. This did not establish an authorization-order defect. I removed that substitution and kept the OpenAPI InstanceConfig request body; Python syntax and whitespace checks pass. I did not rerun the full matrix because this job is limited to one adversarial round.

Follow-up from the chrome-sidebar adversarial round: I traced the matrix's 422 against the current API types. `GET /api/v1/admin/config` returns `AdminConfigView`, whose dedup schedule is a structured view; `PUT` accepts `InstanceConfig`, whose dedup schedule is a cron string. The matrix copied the GET view into the PUT body, so Axum rejected it during JSON extraction before `put_config` reached its admin check. This did not establish an authorization-order defect. I removed that substitution and kept the OpenAPI `InstanceConfig` request body; Python syntax and whitespace checks pass. I did not rerun the full matrix because this job is limited to one adversarial round.
Author
Owner

Finding: PUT /api/v1/admin/config, other body-bearing admin routes, and PUT /api/v1/admin/plugins/{id} validate Axum body extractors before their handler-level role checks. This explains the reported 422 for a standard User on the config route. The auth crate’s role-protected routes use RequireScope<Admin> before body extractors. I am applying a shared server route guard to every server admin route and plugin instance-admin route; the guard will also enforce the existing recent-assertion requirement before parsing on sensitive writes.

Finding: `PUT /api/v1/admin/config`, other body-bearing admin routes, and `PUT /api/v1/admin/plugins/{id}` validate Axum body extractors before their handler-level role checks. This explains the reported 422 for a standard User on the config route. The auth crate’s role-protected routes use `RequireScope<Admin>` before body extractors. I am applying a shared server route guard to every server admin route and plugin instance-admin route; the guard will also enforce the existing recent-assertion requirement before parsing on sensitive writes.
Author
Owner

Additional finding: the auth admin handlers check require_fresh inside the handler after Json extraction for invite creation, passkey re-enrolment, role/disable changes and signup settings. Their RequireScope<Admin> check is already before the body, but stale admin sessions can still observe body validation responses. I am extending that shared extractor with a recent-assertion marker and applying it to those operations before their request extractors.

Additional finding: the auth admin handlers check `require_fresh` inside the handler after `Json` extraction for invite creation, passkey re-enrolment, role/disable changes and signup settings. Their `RequireScope<Admin>` check is already before the body, but stale admin sessions can still observe body validation responses. I am extending that shared extractor with a recent-assertion marker and applying it to those operations before their request extractors.
Author
Owner

Matrix finding/correction: the first local round completed 980 requests with no authorization failures, but its PUT /api/v1/admin/config valid case still copied the GET AdminConfigView. The GET schedule is a structured view, while PUT accepts the InstanceConfig cron string. I removed that substitution and built a validator-safe InstanceConfig body from the OpenAPI request schema. Offline checks confirm valid and malformed cases for all 11 privileged routes with bodies. I did not rerun the real-server round because this job is limited to one adversarial round.

Matrix finding/correction: the first local round completed 980 requests with no authorization failures, but its `PUT /api/v1/admin/config` valid case still copied the GET `AdminConfigView`. The GET schedule is a structured view, while PUT accepts the `InstanceConfig` cron string. I removed that substitution and built a validator-safe `InstanceConfig` body from the OpenAPI request schema. Offline checks confirm valid and malformed cases for all 11 privileged routes with bodies. I did not rerun the real-server round because this job is limited to one adversarial round.
Author
Owner

Repeated observation during Forgejo #191's real-server adversarial round on 2026-09-28. The authz matrix fetched the current valid admin config as Owner, then sent PUT /api/v1/admin/config as the standard user. It expected 403 and received 422. The broad matrix covered 242 operations x 4 identities (968 requests); this was its only reported mismatch. The write was rejected. This agrees with the response-order issue already tracked here.

Repeated observation during Forgejo #191's real-server adversarial round on 2026-09-28. The authz matrix fetched the current valid admin config as Owner, then sent PUT /api/v1/admin/config as the standard user. It expected 403 and received 422. The broad matrix covered 242 operations x 4 identities (968 requests); this was its only reported mismatch. The write was rejected. This agrees with the response-order issue already tracked here.
Author
Owner

The first full cargo test reached the isolated live-app test and found stale expectations: anonymous requests to /api/v1/auth/users and /api/v1/auth/invites, plus an expired cookie on /api/v1/admin/system/codecs, still expected 401. These are protected administration routes and now return the requested uniform 403. Updated those expectations; cargo test -p calternal-server wire::tests::live_apps_run_in_separate_processes -- --exact passes (1 passed, 0 failed).

The first full `cargo test` reached the isolated live-app test and found stale expectations: anonymous requests to `/api/v1/auth/users` and `/api/v1/auth/invites`, plus an expired cookie on `/api/v1/admin/system/codecs`, still expected 401. These are protected administration routes and now return the requested uniform 403. Updated those expectations; `cargo test -p calternal-server wire::tests::live_apps_run_in_separate_processes -- --exact` passes (1 passed, 0 failed).
Author
Owner

Implemented Forgejo #268 on job/authz-order.

Approach: A shared Axum route guard checks the authenticated principal's admin scope and owner/admin role before handler extractors run. Mutations that already required a recent passkey assertion use the same guard with freshness enabled. Auth API routes use a shared FreshAdmin extractor, so scope, role, and freshness checks run before JSON validation. The outer session layer maps missing or invalid credentials on privileged routes to the same 403 response. The adversarial matrix sends valid and malformed bodies for every privileged body route and each identity.

Commits / head: e380d811 guard, 0a34b159 valid OpenAPI config body in the matrix, merge of dev at dc27d075, and 8dd903231a4409b9aca83fe05a83a3deab461ac0 for updated protected-route expectations. The branch push completed (Everything up-to-date).

Files: crates/calternal-auth/src/api.rs, crates/calternal-server/src/authz.rs, crates/calternal-server/src/main.rs, crates/calternal-server/src/wire.rs, tests/adversarial/authz_matrix.py.

Gates and validation:

  • cargo fmt --all -- --check — exit 0, no output.
  • cargo clippy --all-targets -- -D warnings — output:
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 6m 19s
    
  • The single full cargo test run reached the server live-app test and failed on old 401 expectations. Output excerpt:
    assertion `left == right` failed: /api/v1/auth/users
    left: 403
    right: 401
    test result: FAILED. 59 passed; 1 failed; 2 ignored; 0 measured; 0 filtered out; finished in 9.67s
    
    Updated anonymous /auth/users and /auth/invites plus expired-cookie /admin/system/codecs expectations. Focused rerun output:
    test wire::tests::live_apps_run_in_separate_processes ... ok
    test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 61 filtered out
    
  • bun run check output: svelte-check found 0 errors and 0 warnings.
  • bun run test output:
    Test Files  89 passed (89)
         Tests  620 passed (620)
      Duration  71.02s (transform 62%, environment 14%, import 13%, tests 8%, setup 2%)
    
  • One AUTHZ_MATRIX_ONLY=1 tests/adversarial/run.sh round completed with 980 matrix requests across 234 OpenAPI operations and four identities; no policy failures were reported. It reported 11 privileged body routes per identity. The matrix's PUT /admin/config valid body was corrected afterward to use the OpenAPI InstanceConfig request schema and was checked offline; the one-round limit meant the corrected matrix was not sent through a second server run.
  • cargo clean output: Removed 18476 files, 15.6GiB total; apps/web/build was deleted. Worktree is clean.

Known gap: The full workspace test command was not rerun after updating the stale expectations. The focused live-app test passes; all other tests reached before that failure passed. The corrected config matrix body was checked offline but not in another adversarial server round.

Decisions for owner review: Use 403 for unauthenticated and invalid-credential callers on privileged routes to satisfy uniform authorization responses. Preserve recent-assertion requirements only on routes that already required them; role-only admin routes remain role-only. The issue clarification defines the valid config PUT body as InstanceConfig, not the AdminConfigView returned by GET.

Implemented Forgejo #268 on `job/authz-order`. **Approach:** A shared Axum route guard checks the authenticated principal's admin scope and owner/admin role before handler extractors run. Mutations that already required a recent passkey assertion use the same guard with freshness enabled. Auth API routes use a shared `FreshAdmin` extractor, so scope, role, and freshness checks run before JSON validation. The outer session layer maps missing or invalid credentials on privileged routes to the same 403 response. The adversarial matrix sends valid and malformed bodies for every privileged body route and each identity. **Commits / head:** `e380d811` guard, `0a34b159` valid OpenAPI config body in the matrix, merge of `dev` at `dc27d075`, and `8dd903231a4409b9aca83fe05a83a3deab461ac0` for updated protected-route expectations. The branch push completed (`Everything up-to-date`). **Files:** `crates/calternal-auth/src/api.rs`, `crates/calternal-server/src/authz.rs`, `crates/calternal-server/src/main.rs`, `crates/calternal-server/src/wire.rs`, `tests/adversarial/authz_matrix.py`. **Gates and validation:** - `cargo fmt --all -- --check` — exit 0, no output. - `cargo clippy --all-targets -- -D warnings` — output: ``` Finished `dev` profile [unoptimized + debuginfo] target(s) in 6m 19s ``` - The single full `cargo test` run reached the server live-app test and failed on old 401 expectations. Output excerpt: ``` assertion `left == right` failed: /api/v1/auth/users left: 403 right: 401 test result: FAILED. 59 passed; 1 failed; 2 ignored; 0 measured; 0 filtered out; finished in 9.67s ``` Updated anonymous `/auth/users` and `/auth/invites` plus expired-cookie `/admin/system/codecs` expectations. Focused rerun output: ``` test wire::tests::live_apps_run_in_separate_processes ... ok test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 61 filtered out ``` - `bun run check` output: `svelte-check found 0 errors and 0 warnings`. - `bun run test` output: ``` Test Files 89 passed (89) Tests 620 passed (620) Duration 71.02s (transform 62%, environment 14%, import 13%, tests 8%, setup 2%) ``` - One `AUTHZ_MATRIX_ONLY=1 tests/adversarial/run.sh` round completed with 980 matrix requests across 234 OpenAPI operations and four identities; no policy failures were reported. It reported 11 privileged body routes per identity. The matrix's `PUT /admin/config` valid body was corrected afterward to use the OpenAPI `InstanceConfig` request schema and was checked offline; the one-round limit meant the corrected matrix was not sent through a second server run. - `cargo clean` output: `Removed 18476 files, 15.6GiB total`; `apps/web/build` was deleted. Worktree is clean. **Known gap:** The full workspace test command was not rerun after updating the stale expectations. The focused live-app test passes; all other tests reached before that failure passed. The corrected config matrix body was checked offline but not in another adversarial server round. **Decisions for owner review:** Use 403 for unauthenticated and invalid-credential callers on privileged routes to satisfy uniform authorization responses. Preserve recent-assertion requirements only on routes that already required them; role-only admin routes remain role-only. The issue clarification defines the valid config PUT body as `InstanceConfig`, not the `AdminConfigView` returned by GET.
Author
Owner

Starting #268 on branch job/authz-order.

  • Base dev: 088e17d4fe49d6a7b41f03fcc13f5f4959346be9
  • Starting head: 8dd903231a4409b9aca83fe05a83a3deab461ac0

The authorization-before-extraction change is present in e380d811. The latest commit only changed anonymous response expectations from 401 to 403. I am tracing the established auth and step-up response contract, then I will cover anonymous, non-admin and stale-admin callers with valid and malformed request bodies.

Starting #268 on branch `job/authz-order`. - Base `dev`: `088e17d4fe49d6a7b41f03fcc13f5f4959346be9` - Starting head: `8dd903231a4409b9aca83fe05a83a3deab461ac0` The authorization-before-extraction change is present in `e380d811`. The latest commit only changed anonymous response expectations from 401 to 403. I am tracing the established auth and step-up response contract, then I will cover anonymous, non-admin and stale-admin callers with valid and malformed request bodies.
Author
Owner

Finding: the five-minute check in AuthStore::require_fresh returns AuthError::Forbidden; the auth error response is HTTP 403 with JSON code forbidden. apps/web/src/routes/settings/api.svelte.ts uses HTTP 403 to start withStepUp and request a passkey. The route middleware added in e380d811 returns a bare status, and RequireScope plus session_context map anonymous/invalid admin sessions to 403. This changes the web sign-in contract and removes the structured response. I will preserve the early guard while returning the existing auth errors: 401 (unauthenticated or invalid_token) without a principal, 403 (forbidden) for authenticated insufficient authority or stale assertion.

Finding: the five-minute check in `AuthStore::require_fresh` returns `AuthError::Forbidden`; the auth error response is HTTP 403 with JSON code `forbidden`. `apps/web/src/routes/settings/api.svelte.ts` uses HTTP 403 to start `withStepUp` and request a passkey. The route middleware added in `e380d811` returns a bare status, and `RequireScope` plus `session_context` map anonymous/invalid admin sessions to 403. This changes the web sign-in contract and removes the structured response. I will preserve the early guard while returning the existing auth errors: 401 (`unauthenticated` or `invalid_token`) without a principal, 403 (`forbidden`) for authenticated insufficient authority or stale assertion.
Author
Owner

The first local authz matrix round completed 1,039 requests. Anonymous, invalid-session and non-admin response checks passed. The 24 stale-admin probe failures were fixture setup: the matrix reused its sign-out token, and the matrix signed that token out before running the stale-admin probes. I am giving the stale-assertion probe its own disposable session and will verify it on the local server.

The first local authz matrix round completed 1,039 requests. Anonymous, invalid-session and non-admin response checks passed. The 24 stale-admin probe failures were fixture setup: the matrix reused its sign-out token, and the matrix signed that token out before running the stale-admin probes. I am giving the stale-assertion probe its own disposable session and will verify it on the local server.
Author
Owner

The corrected local-server round passed. The fixture now uses a separate admin session for the stale-assertion probe, so the earlier sign-out step cannot revoke it.

Verbatim probe output:

calendar Event tag probe: Unicode/bidi, 65536-byte category, 7 malformed inputs, and 24 parallel reads passed
Authorization matrix: 234 OpenAPI operations; 1039 requests across 4 base identities plus invalid/stale session probes; valid/malformed bodies on 11 privileged body routes per identity; policy classes {'public': 18, 'public_link': 14, 'user': 178, 'admin': 24}

The matrix reported no !! failures.

The corrected local-server round passed. The fixture now uses a separate admin session for the stale-assertion probe, so the earlier sign-out step cannot revoke it. Verbatim probe output: ```text calendar Event tag probe: Unicode/bidi, 65536-byte category, 7 malformed inputs, and 24 parallel reads passed Authorization matrix: 234 OpenAPI operations; 1039 requests across 4 base identities plus invalid/stale session probes; valid/malformed bodies on 11 privileged body routes per identity; policy classes {'public': 18, 'public_link': 14, 'user': 178, 'admin': 24} ``` The matrix reported no `!!` failures.
Author
Owner

Final report — #268

  • Branch: job/authz-order
  • Base dev: 088e17d4fe49d6a7b41f03fcc13f5f4959346be9
  • Head: 55b3dbd9af929fa4acd164f7f15ce02092d14933
  • The branch was pushed to origin/job/authz-order.
  • git merge dev ran before final gates and returned Already up to date.

Built

Restored the existing auth response contract while keeping authorization ahead of body extraction. Requests without a principal now return the auth layer's 401 response. Authenticated callers without the required Role or scope return 403. A stale admin assertion still returns 403 with code forbidden, which triggers the web client's passkey step-up retry.

The adversarial matrix now checks anonymous, invalid-session, authenticated non-admin and stale-admin callers. Protected body routes run with valid and malformed bodies. Its stale assertion uses a dedicated disposable session.

Files

  • crates/calternal-auth/src/api.rs
  • crates/calternal-server/src/authz.rs
  • crates/calternal-server/src/wire.rs
  • tests/adversarial/authz_matrix.py
  • tests/adversarial/setup.mjs
  • apps/web/src/routes/settings/api.svelte.test.ts

Gates

cargo fmt --all --check — exit 0; the command emitted no output.

cargo test -p calternal-server -p calternal-auth:

test result: ok. 50 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 19.83s
test result: ok. 60 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out; finished in 5.24s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

cargo clippy --all-targets -- -D warnings:

Finished `dev` profile [unoptimized + debuginfo] target(s) in 2m 08s

bun run --cwd apps/web check:

svelte-check found 0 errors and 0 warnings

bun run --cwd apps/web test:

Test Files  90 passed (90)
Tests  622 passed (622)

The web test run also printed jsdom messages (Not implemented: Window's scrollTo() method and Could not parse CSS stylesheet); Vitest reported all 622 tests passing.

Real-server focused adversarial output:

calendar Event tag probe: Unicode/bidi, 65536-byte category, 7 malformed inputs, and 24 parallel reads passed
Authorization matrix: 234 OpenAPI operations; 1039 requests across 4 base identities plus invalid/stale session probes; valid/malformed bodies on 11 privileged body routes per identity; policy classes {'public': 18, 'public_link': 14, 'user': 178, 'admin': 24}

The matrix reported no failures.

Known gaps

The adversarial harness ran with AUTHZ_MATRIX_ONLY=1: it covered the auth matrix and calendar-event-tag probe. The broader media, editor, attack.py and attack2.py probes did not run.

Decisions outside the design doc

  • DESIGN §21 defines the five-minute assertion rule but does not specify its HTTP response. I kept the established auth-layer 403 / forbidden response because the web client's withStepUp retries on status 403.
  • To test stale assertions without waiting five minutes, the adversarial fixture creates a separate disposable admin session and sets its asserted_at to NULL in the throwaway test Index. Production code does not use this path.
# Final report — #268 - Branch: `job/authz-order` - Base `dev`: `088e17d4fe49d6a7b41f03fcc13f5f4959346be9` - Head: `55b3dbd9af929fa4acd164f7f15ce02092d14933` - The branch was pushed to `origin/job/authz-order`. - `git merge dev` ran before final gates and returned `Already up to date.` ## Built Restored the existing auth response contract while keeping authorization ahead of body extraction. Requests without a principal now return the auth layer's 401 response. Authenticated callers without the required Role or scope return 403. A stale admin assertion still returns 403 with code `forbidden`, which triggers the web client's passkey step-up retry. The adversarial matrix now checks anonymous, invalid-session, authenticated non-admin and stale-admin callers. Protected body routes run with valid and malformed bodies. Its stale assertion uses a dedicated disposable session. ## Files - `crates/calternal-auth/src/api.rs` - `crates/calternal-server/src/authz.rs` - `crates/calternal-server/src/wire.rs` - `tests/adversarial/authz_matrix.py` - `tests/adversarial/setup.mjs` - `apps/web/src/routes/settings/api.svelte.test.ts` ## Gates `cargo fmt --all --check` — exit 0; the command emitted no output. `cargo test -p calternal-server -p calternal-auth`: ```text test result: ok. 50 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 19.83s test result: ok. 60 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out; finished in 5.24s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s ``` `cargo clippy --all-targets -- -D warnings`: ```text Finished `dev` profile [unoptimized + debuginfo] target(s) in 2m 08s ``` `bun run --cwd apps/web check`: ```text svelte-check found 0 errors and 0 warnings ``` `bun run --cwd apps/web test`: ```text Test Files 90 passed (90) Tests 622 passed (622) ``` The web test run also printed jsdom messages (`Not implemented: Window's scrollTo() method` and `Could not parse CSS stylesheet`); Vitest reported all 622 tests passing. Real-server focused adversarial output: ```text calendar Event tag probe: Unicode/bidi, 65536-byte category, 7 malformed inputs, and 24 parallel reads passed Authorization matrix: 234 OpenAPI operations; 1039 requests across 4 base identities plus invalid/stale session probes; valid/malformed bodies on 11 privileged body routes per identity; policy classes {'public': 18, 'public_link': 14, 'user': 178, 'admin': 24} ``` The matrix reported no failures. ## Known gaps The adversarial harness ran with `AUTHZ_MATRIX_ONLY=1`: it covered the auth matrix and calendar-event-tag probe. The broader media, editor, `attack.py` and `attack2.py` probes did not run. ## Decisions outside the design doc - DESIGN §21 defines the five-minute assertion rule but does not specify its HTTP response. I kept the established auth-layer `403` / `forbidden` response because the web client's `withStepUp` retries on status 403. - To test stale assertions without waiting five minutes, the adversarial fixture creates a separate disposable admin session and sets its `asserted_at` to `NULL` in the throwaway test Index. Production code does not use this path.
Author
Owner

Continuing #268 on job/authz-order after the accepted review.

  • Base dev: 088e17d4fe49d6a7b41f03fcc13f5f4959346be9
  • Starting head: 55b3dbd9af929fa4acd164f7f15ce02092d14933
  • Fetched origin/dev: 976b804f (merging once now as requested).
Continuing #268 on `job/authz-order` after the accepted review. - Base `dev`: `088e17d4fe49d6a7b41f03fcc13f5f4959346be9` - Starting head: `55b3dbd9af929fa4acd164f7f15ce02092d14933` - Fetched `origin/dev`: `976b804f` (merging once now as requested).
Author
Owner

Final report — #268 merge and verification

  • Branch: job/authz-order
  • Merged origin/dev at 976b804f99188feb300e391c1942836599e2d429 once.
  • Resolved the sole conflict in tests/adversarial/authz_matrix.py. The resolution keeps the OpenAPI InstanceConfig body for config writes, the valid and malformed request cases, and the TOML config fixture.
  • Head: f32d8522dc8ecb63e90f1ceffb7521558b52d5eb

Built

The accepted authorization-before-extraction guards remain in place for instance-admin and plugin-admin routes. The guard checks admin scope, Role and required assertion freshness before body extraction. The matrix checks valid and malformed bodies for each privileged body route and preserves the existing response contract: no principal gets 401; an authenticated caller without authority or with a stale assertion gets 403.

Files

  • crates/calternal-auth/src/api.rs
  • crates/calternal-server/src/authz.rs
  • crates/calternal-server/src/main.rs
  • crates/calternal-server/src/wire.rs
  • tests/adversarial/authz_matrix.py
  • tests/adversarial/setup.mjs
  • apps/web/src/routes/settings/api.svelte.test.ts

Gates and validation

cargo fmt --check — exit 0; no output.

cargo clippy --all-targets -- -D warnings — exit 0:

    Finished `dev` profile [unoptimized + debuginfo] target(s) in 9m 33s

cargo test — exit 0; 72 summaries, 1,332 passed, 0 failed, 12 ignored. The exact per-target summaries follow:

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
test result: ok. 50 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 39.62s
test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.85s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.47s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.82s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 58.64s
test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.97s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.71s
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.53s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 11.02s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.53s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 39.65s
test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.12s
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 19.95s
test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.79s
test result: ok. 9 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.91s
test result: ok. 17 passed; 0 failed; 4 ignored; 0 measured; 0 filtered out; finished in 0.32s
test result: ok. 35 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 14.34s
test result: ok. 40 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 6.13s
test result: ok. 486 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.26s
test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.38s
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.76s
test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
test result: ok. 21 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 9.11s
test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.28s
test result: ok. 29 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.96s
test result: ok. 47 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.28s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.62s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s
test result: ok. 117 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 34.02s
test result: ok. 95 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 49.50s
test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.29s
test result: ok. 42 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out; finished in 3.42s
test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s
test result: ok. 25 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.91s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.83s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.10s
test result: ok. 14 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.99s
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
test result: ok. 1 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out; finished in 3.22s
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 60 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out; finished in 6.57s
test result: ok. 51 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.15s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.30s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

bun run --cwd apps/web check — exit 0:

svelte-check found 0 errors and 0 warnings

bun run --cwd apps/web test — exit 0:

 Test Files  91 passed (91)
      Tests  631 passed (631)
   Duration  103.52s (transform 64%, environment 13%, import 11%, tests 8%, setup 2%)

The web test output also included jsdom notices: Not implemented: Window's scrollTo() method and Could not parse CSS stylesheet. Vitest reported all 631 tests passing.

packages/api-client/check-generated.sh — exit 0; the OpenAPI contract and generated client were regenerated from the merged source and passed the stale-output check.

One real-server adversarial round passed:

calendar Event tag probe: Unicode/bidi, 65536-byte category, 7 malformed inputs, and 24 parallel reads passed
Authorization matrix: 236 OpenAPI operations; 1047 requests across 4 base identities plus invalid/stale session probes; valid/malformed bodies on 11 privileged body routes per identity; policy classes {'public': 18, 'public_link': 14, 'user': 180, 'admin': 24}

Known gaps

This time-boxed round selected the authorization matrix and calendar Event tag probes. The broader media, editor, attack.py and attack2.py probes did not run. The selected round reported no failures.

Decisions outside DESIGN.md

No new design decision was needed for the merge. The existing #268 choices remain: keep the auth-layer 401 for a missing or invalid principal, use the established 403 forbidden response for insufficient authority and stale assertions, and build config PUT probe bodies from InstanceConfig rather than the AdminConfigView returned by GET.

Delivery

cargo clean — output:

     Removed 19059 files, 14.6GiB total

git push origin job/authz-order — exit 0:

Everything up-to-date
# Final report — #268 merge and verification - Branch: `job/authz-order` - Merged `origin/dev` at `976b804f99188feb300e391c1942836599e2d429` once. - Resolved the sole conflict in `tests/adversarial/authz_matrix.py`. The resolution keeps the OpenAPI `InstanceConfig` body for config writes, the valid and malformed request cases, and the TOML config fixture. - Head: `f32d8522dc8ecb63e90f1ceffb7521558b52d5eb` ## Built The accepted authorization-before-extraction guards remain in place for instance-admin and plugin-admin routes. The guard checks admin scope, Role and required assertion freshness before body extraction. The matrix checks valid and malformed bodies for each privileged body route and preserves the existing response contract: no principal gets 401; an authenticated caller without authority or with a stale assertion gets 403. ## Files - `crates/calternal-auth/src/api.rs` - `crates/calternal-server/src/authz.rs` - `crates/calternal-server/src/main.rs` - `crates/calternal-server/src/wire.rs` - `tests/adversarial/authz_matrix.py` - `tests/adversarial/setup.mjs` - `apps/web/src/routes/settings/api.svelte.test.ts` ## Gates and validation `cargo fmt --check` — exit 0; no output. `cargo clippy --all-targets -- -D warnings` — exit 0: ```text Finished `dev` profile [unoptimized + debuginfo] target(s) in 9m 33s ``` `cargo test` — exit 0; 72 summaries, 1,332 passed, 0 failed, 12 ignored. The exact per-target summaries follow: ```text test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s test result: ok. 50 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 39.62s test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.85s test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.47s test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.82s test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 58.64s test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.97s test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.71s test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.53s test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 11.02s test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.53s test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 39.65s test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.12s test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 19.95s test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.79s test result: ok. 9 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.91s test result: ok. 17 passed; 0 failed; 4 ignored; 0 measured; 0 filtered out; finished in 0.32s test result: ok. 35 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 14.34s test result: ok. 40 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 6.13s test result: ok. 486 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.26s test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.38s test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.76s test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s test result: ok. 21 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 9.11s test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.28s test result: ok. 29 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.96s test result: ok. 47 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.28s test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.62s test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s test result: ok. 117 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 34.02s test result: ok. 95 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 49.50s test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.29s test result: ok. 42 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out; finished in 3.42s test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s test result: ok. 25 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.91s test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.83s test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.10s test result: ok. 14 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.99s test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s test result: ok. 1 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out; finished in 3.22s test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s test result: ok. 60 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out; finished in 6.57s test result: ok. 51 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.15s test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.30s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s ``` `bun run --cwd apps/web check` — exit 0: ```text svelte-check found 0 errors and 0 warnings ``` `bun run --cwd apps/web test` — exit 0: ```text Test Files 91 passed (91) Tests 631 passed (631) Duration 103.52s (transform 64%, environment 13%, import 11%, tests 8%, setup 2%) ``` The web test output also included jsdom notices: `Not implemented: Window's scrollTo() method` and `Could not parse CSS stylesheet`. Vitest reported all 631 tests passing. `packages/api-client/check-generated.sh` — exit 0; the OpenAPI contract and generated client were regenerated from the merged source and passed the stale-output check. One real-server adversarial round passed: ```text calendar Event tag probe: Unicode/bidi, 65536-byte category, 7 malformed inputs, and 24 parallel reads passed Authorization matrix: 236 OpenAPI operations; 1047 requests across 4 base identities plus invalid/stale session probes; valid/malformed bodies on 11 privileged body routes per identity; policy classes {'public': 18, 'public_link': 14, 'user': 180, 'admin': 24} ``` ## Known gaps This time-boxed round selected the authorization matrix and calendar Event tag probes. The broader media, editor, `attack.py` and `attack2.py` probes did not run. The selected round reported no failures. ## Decisions outside DESIGN.md No new design decision was needed for the merge. The existing #268 choices remain: keep the auth-layer 401 for a missing or invalid principal, use the established 403 `forbidden` response for insufficient authority and stale assertions, and build config PUT probe bodies from `InstanceConfig` rather than the `AdminConfigView` returned by GET. ## Delivery `cargo clean` — output: ```text Removed 19059 files, 14.6GiB total ``` `git push origin job/authz-order` — exit 0: ```text Everything up-to-date ```
Author
Owner

Post-gate ref update: origin/dev advanced from the merged 976b804f99188feb300e391c1942836599e2d429 to 55a974d32c5ce1a5e138d4ff04db3db2a5b0855f (Merge job/event-stripes (#279)) while I was running the gates. The pushed head f32d8522dc8ecb63e90f1ceffb7521558b52d5eb has merge parents 55b3dbd9af929fa4acd164f7f15ce02092d14933 and 976b804f99188feb300e391c1942836599e2d429. I did not merge again, following the one-merge/no-loop instruction.

Post-gate ref update: `origin/dev` advanced from the merged `976b804f99188feb300e391c1942836599e2d429` to `55a974d32c5ce1a5e138d4ff04db3db2a5b0855f` (`Merge job/event-stripes (#279)`) while I was running the gates. The pushed head `f32d8522dc8ecb63e90f1ceffb7521558b52d5eb` has merge parents `55b3dbd9af929fa4acd164f7f15ce02092d14933` and `976b804f99188feb300e391c1942836599e2d429`. I did not merge again, following the one-merge/no-loop instruction.
kayg referenced this issue from a commit 2026-09-28 01:21:38 +00:00
Author
Owner

Merged in ab1f0069: authorization before extraction on every privileged route; 401 anonymous / 403 non-admin / 403 step-up for stale admin (unchanged contract). Clippy clean; server+auth tests 110/110.

Merged in ab1f0069: authorization before extraction on every privileged route; 401 anonymous / 403 non-admin / 403 step-up for stale admin (unchanged contract). Clippy clean; server+auth tests 110/110.
kayg closed this issue 2026-09-28 01:21:39 +00:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal#268
No description provided.