Skip to content

fix(wiki-v2#598): refuse the native space create in remote mode - #61

Merged
PMQ9 merged 1 commit into
wiki-v2from
fix/598-remote-native-space-create
Sep 27, 2026
Merged

PMQ9 merged 1 commit into
wiki-v2from
fix/598-remote-native-space-create

Conversation

@PMQ9

@PMQ9 PMQ9 commented Sep 26, 2026

Copy link
Copy Markdown

TL;DR

This is the fork half of the fix for cccvu/wiki-v2 issue 598. In AUTHZ_MODE=remote, the engine's native space create (POST /api/spaces/create) now answers 404 for every authenticated caller, owner sessions included, and the refusal is audited. The integrating platform's POST /api/service/spaces is the only create path. Native (standalone) mode is unchanged.

Context

  • The gate. Upstream gates the route only by workspace CASL on users.role (owner/admin, WorkspaceAbilityFactory), and that check is not PDP-backed. Remote mode refused it only because no fork session held owner/admin.
  • Why that wasn't enough. JwtStrategy.validate doesn't re-check role or namespace, so a session minted before the switch to remote survives it: first-run setup mints a 90-day owner session, and this repo's contract smoke uses native-phase sessions on purpose.
  • Reproduced. The contract smoke on the unfixed fork shows the owner session gets 200 in remote mode and a spaces row is written.
  • What that bypasses. The space and its creator's space_members admin row are captured by the outbox triggers and projected into the PDP. That bypasses the platform's governed create, its audit and its connector-posture bind (wiki-v2 ADR 0028).

Related issues

  • Refs cccvu/wiki-v2 issue 598. The paired super PR closes it.
  • Follow-ups in cccvu/wiki-v2:
    • issue 636: other native workspace/group admin routes gated only by users.role;
    • issue 590: post-release prod check.

What changed

No controller, service or repo is touched. The only upstream-owned edit is the composition registration in app.module.ts (seam #4).

  • authz/space-create/space-native-create.interceptor.ts (new, CCC-owned): SpaceNativeCreateInterceptor, a sibling of Dev docmost/docmost#502's SpaceHardDeleteInterceptor.
    • Keyed SpaceController.createSpace. In remote mode it returns throwError(() => new NotFoundException()) before the handler, so CASL, the ValidationPipe and the insert never run.
    • It forwards one space.native_create_refused row through PlatformAuditClient directly (the API_AUDIT_ENABLED kill switch cannot drop it): metadata { outcome: 'denied', reason: 'platform_only', route, actorRole? }.
    • The body is never read (name and slug are free text). actorRole is recorded only as a known UserRole value, so an attempt by a leftover privileged session stands out.
    • A failing forward never changes the 404. With no actor it writes no row.
  • app.module.ts (seam fix(authz): harden PLATFORM_AUTHZ_TIMEOUT_MS parse (empty/typo → deny-all self-DoS) #4): registered as an APP_INTERCEPTOR right after SpaceHardDeleteInterceptor, so after the per-principal rate limiter.
  • authz/route-guard/static-route-scan.ts: a callsNativeSpaceCreate handler-body tell.
  • authz/route-guard/ast-sink-scan.ts (new): literalText, ownerOf and the source-file walk, shared by both fitness specs.
    • Fix: a sink bound to a local (const s = sql\…``) is now attributed to its enclosing method, not the local's name, so a sink moved between methods changes the inventory.
    • Dev docmost/docmost#502's spec now uses it, with unchanged expectations plus a regression case.
  • examples/integrations/authorization-stub/contract-smoke.sh: Phase 3 check (d). Alice's native-phase OWNER session gets POST /api/spaces/create → 404, and there is no spaces row.
  • docs/integrations/authorization/README.md: "Native space routes refused in remote mode" (Dev docmost/docmost#502 delete, custom drawio server by docker not ok docmost/docmost#598 create). No OpenAPI or version change.

How this was tested

  • pnpm exec jest src/authz/space-create src/authz/space-delete src/authz/route-guard src/authz/request-controls src/authz/mode → 18 suites, 157 tests green:
    • unit: every body shape, a throwing body getter, the role enum, native inert, non-HTTP pass-through;
    • HTTP: the real SpaceController on Fastify with an owner whose CASL allows gets 404, and CASL and createSpace never run; without the interceptor it gets 200 (anti-vacuity); it composes with Dev docmost/docmost#502; native {} gets 400 and a valid create reaches the service;
    • fitness: the full inventory plus the meta-guard.
  • Mutation: keying the interceptor to a wrong handler turns all three new specs red (23 failures).
  • nest build is clean. tsc --noEmit shows no errors in the touched files. eslint is clean.
  • contract-smoke.sh end to end:
    • with the fix: ALL CHECKS PASSED, including owner → 404 and no row;
    • on unfixed wiki-v2 HEAD: FAIL: … expected 404 in remote mode, got HTTP 200 and still wrote 1 space row(s).
  • Local super stack (remote mode, built from this branch):
    • the brokered engine session's native create → 404;
    • the platform audit_log has space.native_create_refused (actorRole: member, actor resolved) plus api.post status 404;
    • /v1 create still returns 201;
    • native delete still returns 404.

AWS / infra apply

N/A

Checklist

Scope

  • Complete and scoped to one logical concern — no unrelated drive-by changes (or follow-ups noted below).

Tests

  • New/changed behavior has appropriate tests — unit / integration / e2e / security / regression as applicable (or N/A, with why).
  • Existing tests pass, and any new tests pass.

Docs & contracts

  • Docs added/updated where needed (README / docs/ / an ADR), or N/A.
  • Any API/contract, config, migration, permission, or deployment impact is called out in this PR, or N/A.
  • Any AWS/infra apply is described precisely, scoped (no mass-apply), and captured in a reviewable one-time script — see "AWS / infra apply" above, or N/A.

Safety (this is a security-first, deny-by-default platform)

  • Security, authorization, data integrity, failure paths, and backward compatibility considered, or N/A.
  • Upstream boundary respected — no undocumented edits to Docmost-owned files (see UPSTREAM_MODIFICATIONS.md); proprietary/authz logic stays out-of-process.

Housekeeping

  • Related issue(s) linked above.
  • Known limitations, follow-ups, or intentionally deferred work identified below.

Known limitations / follow-ups

  • Contract change for integrators. A remote-mode integrator that created spaces through the native route must use POST /api/service/spaces. This is documented in the README. No in-repo caller used it in remote mode.
  • Static-inventory limit (same as Dev docmost/docmost#502). A sink reached by a method reference or a dynamic property name is invisible. SpaceService injection sites are pinned so the receiver-name match stays sound.
  • No client seam. "Create space" is gated by users.role, which is member for every brokered session.
  • Tracked in cccvu/wiki-v2 issue 636: the wider users.role-only class and surviving privileged sessions.

Merge: this PR first (merge commit), then re-pin the gitlink in the super PR.

🤖 Generated with Claude Code

In AUTHZ_MODE=remote, POST /api/spaces/create was gated only by upstream
workspace CASL on users.role, which is not PDP-backed. Remote mode relied on
no fork session holding owner/admin, but a session minted before the switch
to remote survives it, and the space plus its creator's admin row are
projected into SpiceDB, bypassing the platform's governed create, its audit
and the connector-posture bind (ADR 0028).

- SpaceNativeCreateInterceptor (authz/space-create/), registered after the
  docmost#502 refusal in app.module.ts (seam #4): 404 for every authenticated caller
  before CASL, the pipe and the handler; one space.native_create_refused row
  via PlatformAuditClient. The body is never read; the engine role is
  recorded only as an enum value. Native mode is unchanged.
- Fitness spec: the refused set equals the routes reaching .createSpace(;
  every spaces row insert and every hop up to a route pinned per method.
- Shared AST helper (route-guard/ast-sink-scan.ts): a sink bound to a local
  is attributed to its method, for docmost#502's inventory too.
- Contract smoke: a native-phase OWNER session in remote mode gets 404 and
  no row. Integrator README documents both refused native routes.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
@PMQ9
PMQ9 merged commit 46ad75d into wiki-v2 Sep 27, 2026
@PMQ9
PMQ9 deleted the fix/598-remote-native-space-create branch September 27, 2026 01:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant