Task — engineering-spec@1

"KB 3 permission tiers — public / org-only / role-restricted with share-link tokens for time-bounded external access"

draftTASK-KB-003
module kb · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-KB-001 · blocks TASK-KB-004

§1 — Description (BCP-14 normative)

The KB service MUST ship 3-tier permission system at services/kb/src/permission/ with share-link tokens, 4 memory audit kinds.

  1. MUST validate visibility_tier against closed enum per DEC-1901.
  1. MUST gate access at access_gate.rs::check(doc, user, share_token?):
  1. MUST define table extension + share-link table at migration 0003: ```sql ALTER TABLE kb_documents ADD COLUMN visibility_tier TEXT NOT NULL DEFAULT 'org_only' CHECK (visibility_tier IN ('public','org_only','role_restricted')); ALTER TABLE kb_documents ADD COLUMN allowed_roles TEXT[] NOT NULL DEFAULT '{}'; CREATE INDEX docs_visibility_idx ON kb_documents(tenant_id, visibility_tier); GRANT UPDATE (visibility_tier, allowed_roles) ON kb_documents TO cyberos_app;

CREATE TABLE kb_share_links ( token_jti UUID PRIMARY KEY, tenant_id UUID NOT NULL, doc_id UUID NOT NULL, created_by UUID NOT NULL, expires_at TIMESTAMPTZ NOT NULL, max_uses INT NOT NULL DEFAULT 0, -- 0 = unlimited used_count INT NOT NULL DEFAULT 0, revoked_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX share_links_doc_idx ON kb_share_links(tenant_id, doc_id); ALTER TABLE kb_share_links ENABLE ROW LEVEL SECURITY; CREATE POLICY share_links_rls ON kb_share_links USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON kb_share_links FROM cyberos_app; GRANT UPDATE (used_count, revoked_at) ON kb_share_links TO cyberos_app; ```

  1. MUST create share-links at share_link.rs::create(doc_id, expires_in, max_uses?) per DEC-1902 — CDO-only.
  1. MUST validate share-link on use per DEC-1902:
  1. MUST expose endpoints: ``text PUT /v1/kb/docs/{id}/visibility body: {tier, allowed_roles?} POST /v1/kb/docs/{id}/share-links body: {expires_in_seconds, max_uses?} POST /v1/kb/share-links/{jti}/revoke GET /v1/kb/docs/{id}?share_token=... (share-link access path) ``
  1. MUST emit 4 memory audit kinds per DEC-1904. PII per TASK-MEMORY-111: tier+role enums ok; share_token only jti in chain.
  1. MUST thread trace_id from gate → audit.
  1. MUST NOT serve role_restricted to non-matching role per DEC-1903.
  1. MUST NOT accept expired share-link per DEC-1902.
  1. MUST NOT accept share-link past max_uses per DEC-1902.

§2 — Why this design

Why 3 tiers (DEC-1900)? Industry-standard public/private/role split; covers 95% of KB use cases.

Why share-links (DEC-1902)? Customer collaboration needs external read access without account creation; tokens provide time-bounded grant.

Why max_uses (DEC-1902)? Single-use links (max_uses=1) prevent forwarding; mass-share allows broader distribution.

Why role match (DEC-1903)? Executive comp docs need ROOT-CHRO scope; engineering runbooks need engineer scope.


§3 — API contract

PUT    /v1/kb/docs/{id}/visibility
POST   /v1/kb/docs/{id}/share-links
POST   /v1/kb/share-links/{jti}/revoke
GET    /v1/kb/docs/{id}?share_token=<jwt>

Sample share-link creation:

{
  "expires_in_seconds": 86400,
  "max_uses": 5
}

Response:

{
  "token_jti": "uuid",
  "share_url": "https://kb.cyberskill.com/d/abc123?share_token=eyJhbGc...",
  "expires_at": "2026-05-18T10:00:00Z",
  "max_uses": 5
}

§4 — Acceptance criteria

  1. visibility_tier enum cardinality 3. 2. public served unauth. 3. org_only requires session. 4. role_restricted requires role match. 5. Share-link JWT signed + verified. 6. expires_at enforced. 7. max_uses enforced (0=unlimited). 8. Revoke endpoint works. 9. used_count incremented atomically. 10. 4 memory audit kinds emitted. 11. Access denied → audit. 12. RLS denies cross-tenant. 13. CDO-only create + revoke. 14. Trace_id preserved. 15. Append-only share_links via REVOKE except used_count + revoked_at. 16. default tier = org_only on new doc. 17. Multiple share-links per doc supported. 18. Share-link survives doc version updates. 19. Revoked share-link cannot be undone. 20. token_jti UUID prevents collision.

§5 — Verification

#[tokio::test]
async fn public_doc_unauth_serve() {
    let ctx = TestContext::with_public_doc().await;
    let r = ctx.fetch_doc_unauth(ctx.doc_id).await;
    assert_eq!(r.status_code, 200);
}

#[tokio::test]
async fn role_restricted_denies_wrong_role() {
    let ctx = TestContext::with_role_restricted_doc(vec!["ROOT-CHRO"]).await;
    let r = ctx.fetch_doc_as(ctx.am_user, ctx.doc_id).await;
    assert_eq!(r.status_code, 403);
}

#[tokio::test]
async fn share_link_expiry_enforced() {
    let ctx = TestContext::with_share_link_expires_in(1).await;
    tokio::time::sleep(Duration::from_secs(2)).await;
    let r = ctx.fetch_via_share_token(ctx.token).await;
    assert_eq!(r.status_code, 403);
}

#[tokio::test]
async fn share_link_max_uses_enforced() {
    let ctx = TestContext::with_share_link_max_uses(3).await;
    for _ in 0..3 { assert!(ctx.use_share_token().await.is_ok()); }
    let r = ctx.use_share_token().await;
    assert!(r.is_err());
}

// 5.5..5.10

§7 — Dependencies

Upstream: TASK-KB-001. Cross-module: TASK-AUTH-101 (role check), TASK-MEMORY-111 (audit), TASK-AUTH-105 (KMS for JWT signing key).

§10 — Failure modes

FailureDetectionOutcomeRecovery
Invalid JWT signatureverify401 + sev-2inherent
Token expiredtimestamp403new link
Max uses reachedcheck + increment403new link
Revoked share-link useflag403new link
Role mismatchcheck403 + sev-3request access
Public doc on non-public serviceguardinherentinherent
Cross-tenant share-linkRLS0 rowsinherent
Atomic increment racerow-level lockinherentinherent
JWT signing key rotationdual-key windowinherentinherent
Share-link to deleted doccheck404inherent

§11 — Implementation notes


End of TASK-KB-003 spec.