c2f566512a
Implement slice S3 of the §22 three-tier refactor: the four-layer
most-permissive scope-role resolver (§B.2) over {owner, contributor}
grants at {global, project, collection}, with the §22.5 visibility gate
enforced at the collection grain.
- migration 030: memberships.scope_type += 'global' (the global RFC
Contributor tier; sentinel scope_id '*').
- auth.effective_scope_role folds global → project → collection,
most-permissive, no negative override; can_read_collection /
can_contribute_in_collection / is_collection_superuser /
can_create_collection gate reads, writes, admin, and create.
- collection-grain visibility: a gated collection is hidden from the
public (404, omitted from the directory) yet visible+listed for a
scope-role holder; a collection may be set only as strict or stricter
than its project (public < unlisted < gated), validated at create and
clamped at the mirror.
- entry-scoped authority (mark-reviewed, graduate, branch read/contribute,
PR/discussion/contribution moderation) re-pointed from the project grain
to the entry's collection.
- create-collection authority widened to a project/global-scope grant
holder (§B.1), not only a deployment owner/admin.
Keystone reconciliation (session 0076): a plain granted account is a
granted *account*, not a write-everywhere global role; the implicit-public
write baseline is grandfathered onto the migration-seeded `default`
collection only, so the N=1 deployment loses no capability. Reinterprets
§B.1/§B.3 literally — flagged for the SPEC merge (S6).
Completes @S3 (C1.1–C1.8). Tests: test_s3_scope_roles_vertical.py (8 C.1
scenarios + visibility/strictness), test_migration_030_global_scope.py.
Full backend suite 493 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
288 lines
13 KiB
Python
288 lines
13 KiB
Python
"""Slice S3 — scope-role enforcement + collection-grain visibility (@S3).
|
|
|
|
The acceptance gate for S3 is "every Part C.1 scenario passes" (the design doc
|
|
docs/design/2026-06-05-three-tier-projects-collections.md, §C.1, tagged @S3) plus
|
|
the operator's S3 visibility requirements (a collection settable public/hidden;
|
|
hidden = visible to project/global scope contributors but not the public; a
|
|
collection's visibility may be set only as strict or stricter than its project).
|
|
|
|
The §B.2 resolver folds four layers — global → project → collection → per-entry —
|
|
most-permissively, with no negative override. The scenarios below are exercised
|
|
directly against the resolver/gate helpers, and the visibility ones additionally
|
|
through the HTTP surface.
|
|
|
|
Background (C.1): a deployment with a project "ohm" owning collections "model"
|
|
(document) and "features" (bdd); a second project "acme" with collection
|
|
"specs". Plus a hidden ("gated") collection "secret" under ohm for the
|
|
hidden-from-public scenarios.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
|
app_with_fake_gitea,
|
|
provision_user_row,
|
|
sign_in_as,
|
|
tmp_env,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _su(user_id: int, login: str, role: str = "contributor", *, state: str = "granted"):
|
|
from app import auth
|
|
|
|
return auth.SessionUser(
|
|
user_id=user_id, gitea_id=user_id, gitea_login=login,
|
|
display_name=login.capitalize(), email=f"{login}@test", avatar_url="",
|
|
role=role, permission_state=state,
|
|
)
|
|
|
|
|
|
def _project(pid: str, visibility: str = "public", content_repo: str = "meta") -> None:
|
|
from app import db
|
|
|
|
db.conn().execute(
|
|
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) "
|
|
"VALUES (?, ?, ?, ?, datetime('now'))",
|
|
(pid, pid.capitalize(), content_repo, visibility),
|
|
)
|
|
|
|
|
|
def _collection(cid: str, project_id: str, *, ctype: str = "document",
|
|
visibility: str = "public", subfolder: str | None = None) -> None:
|
|
from app import db
|
|
|
|
db.conn().execute(
|
|
"INSERT OR REPLACE INTO collections "
|
|
"(id, project_id, type, subfolder, initial_state, visibility, name, created_at, updated_at) "
|
|
"VALUES (?, ?, ?, ?, 'super-draft', ?, ?, datetime('now'), datetime('now'))",
|
|
(cid, project_id, ctype, subfolder if subfolder is not None else cid,
|
|
visibility, cid.capitalize()),
|
|
)
|
|
|
|
|
|
def _grant(scope_type: str, scope_id: str, user_id: int, role: str) -> None:
|
|
from app import db
|
|
|
|
db.conn().execute(
|
|
"INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) "
|
|
"VALUES (?, ?, ?, ?)",
|
|
(scope_type, scope_id, user_id, role),
|
|
)
|
|
|
|
|
|
def _seed_world() -> None:
|
|
"""The C.1 background plus a hidden collection and a second project."""
|
|
_project("ohm", "public")
|
|
_collection("ohm", "ohm", subfolder="") # ohm's structural default
|
|
_collection("model", "ohm", ctype="document")
|
|
_collection("features", "ohm", ctype="bdd")
|
|
_collection("secret", "ohm", visibility="gated") # hidden from public
|
|
_project("acme", "public")
|
|
_collection("specs", "acme", ctype="specification")
|
|
# the cast
|
|
for uid, login in [(1, "ada"), (2, "ben"), (3, "cleo"), (4, "dan"),
|
|
(5, "eve"), (6, "fay"), (7, "gil"), (8, "hana")]:
|
|
provision_user_row(user_id=uid, login=login, role="contributor")
|
|
_grant("collection", "model", 1, "contributor") # ada
|
|
_grant("project", "ohm", 2, "contributor") # ben
|
|
_grant("global", "*", 3, "contributor") # cleo
|
|
_grant("collection", "features", 4, "owner") # dan
|
|
_grant("project", "ohm", 5, "owner") # eve
|
|
_grant("collection", "model", 6, "contributor") # fay (+ project owner below)
|
|
_grant("project", "ohm", 6, "owner") # fay
|
|
_grant("project", "ohm", 7, "contributor") # gil
|
|
# hana (8): no grant.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# C.1 — role usage: inheritance and the most-permissive union
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_c1_1_collection_contributor_proposes_only_in_that_collection(app_with_fake_gitea):
|
|
from app import auth
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app):
|
|
_seed_world()
|
|
ada = _su(1, "ada")
|
|
# may submit a new entry in ohm/model
|
|
assert auth.can_contribute_in_collection(ada, "model") is True
|
|
# ohm/features is read-only and propose is not offered
|
|
assert auth.can_read_collection(ada, "features") is True
|
|
assert auth.can_contribute_in_collection(ada, "features") is False
|
|
|
|
|
|
def test_c1_2_project_contributor_proposes_in_every_collection(app_with_fake_gitea):
|
|
from app import auth
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app):
|
|
_seed_world()
|
|
ben = _su(2, "ben")
|
|
assert auth.can_contribute_in_collection(ben, "model") is True
|
|
assert auth.can_contribute_in_collection(ben, "features") is True
|
|
# a collection added later is writable with no new grant
|
|
_collection("roadmap", "ohm", ctype="document")
|
|
assert auth.can_contribute_in_collection(ben, "roadmap") is True
|
|
|
|
|
|
def test_c1_3_global_contributor_proposes_everywhere(app_with_fake_gitea):
|
|
from app import auth
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app):
|
|
_seed_world()
|
|
cleo = _su(3, "cleo")
|
|
assert auth.can_contribute_in_collection(cleo, "model") is True
|
|
assert auth.can_contribute_in_collection(cleo, "specs") is True # acme
|
|
|
|
|
|
def test_c1_4_collection_owner_administers_one_collection_only(app_with_fake_gitea):
|
|
from app import auth
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app):
|
|
_seed_world()
|
|
dan = _su(4, "dan")
|
|
# graduate / mark-reviewed / manage membership in ohm/features
|
|
assert auth.is_collection_superuser(dan, "features") is True
|
|
# but not change ohm project settings
|
|
assert auth.is_project_superuser(dan, "ohm") is False
|
|
assert auth.can_create_collection(dan, "ohm") is False
|
|
# and not act on entries in ohm/model
|
|
assert auth.is_collection_superuser(dan, "model") is False
|
|
assert auth.can_contribute_in_collection(dan, "model") is False
|
|
|
|
|
|
def test_c1_5_project_owner_administers_all_collections_and_creates_more(app_with_fake_gitea):
|
|
from app import auth
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app):
|
|
_seed_world()
|
|
eve = _su(5, "eve")
|
|
assert auth.is_collection_superuser(eve, "model") is True
|
|
assert auth.is_collection_superuser(eve, "features") is True
|
|
assert auth.is_project_superuser(eve, "ohm") is True # edit project settings
|
|
assert auth.can_create_collection(eve, "ohm") is True # create a new collection
|
|
|
|
|
|
def test_c1_6_most_permissive_union_higher_grant_wins(app_with_fake_gitea):
|
|
from app import auth
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app):
|
|
_seed_world()
|
|
fay = _su(6, "fay")
|
|
# collection RFC Contributor at model + project Owner at ohm → acts as Owner in model
|
|
assert auth.effective_scope_role(fay, "model") == "owner"
|
|
assert auth.is_collection_superuser(fay, "model") is True
|
|
|
|
|
|
def test_c1_7_no_negative_override(app_with_fake_gitea):
|
|
from app import auth, db
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app):
|
|
_seed_world()
|
|
gil = _su(7, "gil")
|
|
# gil can propose in ohm/model via the project grant…
|
|
assert auth.can_contribute_in_collection(gil, "model") is True
|
|
# …and there is no collection-scope row to remove at model while keeping
|
|
# the project grant (a child cannot subtract a parent grant).
|
|
row = db.conn().execute(
|
|
"SELECT 1 FROM memberships WHERE user_id = 7 AND scope_type = 'collection' AND scope_id = 'model'"
|
|
).fetchone()
|
|
assert row is None
|
|
|
|
|
|
def test_c1_8_granted_account_no_role_sees_only_public(app_with_fake_gitea):
|
|
from app import auth
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app):
|
|
_seed_world()
|
|
hana = _su(8, "hana")
|
|
# may read public collections
|
|
assert auth.can_read_collection(hana, "model") is True
|
|
# but is not offered the propose action anywhere (no scope role; the
|
|
# grandfathered baseline covers only the N=1 `default` collection)
|
|
assert auth.can_contribute_in_collection(hana, "model") is False
|
|
assert auth.can_contribute_in_collection(hana, "features") is False
|
|
assert auth.can_contribute_in_collection(hana, "specs") is False
|
|
# gated (hidden) collections do not appear for her
|
|
assert auth.can_read_collection(hana, "secret") is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Collection-grain visibility — the operator's S3 requirements
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_hidden_collection_invisible_to_public_visible_to_scope_holder(app_with_fake_gitea):
|
|
"""A gated collection is omitted from the directory and 404s on read for the
|
|
public, yet is listed + readable for a scope-role contributor."""
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
_seed_world()
|
|
# anonymous: the gated 'secret' collection is not listed, and 404s.
|
|
listed = {c["id"] for c in client.get("/api/projects/ohm/collections").json()["items"]}
|
|
assert "secret" not in listed
|
|
assert "model" in listed # public ones still listed
|
|
assert client.get("/api/projects/ohm/collections/secret").status_code == 404
|
|
assert client.get("/api/projects/ohm/collections/secret/rfcs").status_code == 404
|
|
|
|
# ben (project contributor) sees and reads it.
|
|
sign_in_as(client, user_id=2, gitea_login="ben", display_name="Ben", role="contributor")
|
|
listed2 = {c["id"] for c in client.get("/api/projects/ohm/collections").json()["items"]}
|
|
assert "secret" in listed2
|
|
assert client.get("/api/projects/ohm/collections/secret").status_code == 200
|
|
assert client.get("/api/projects/ohm/collections/secret/rfcs").status_code == 200
|
|
|
|
# hana (granted, no role) is back to the public view.
|
|
sign_in_as(client, user_id=8, gitea_login="hana", display_name="Hana", role="contributor")
|
|
listed3 = {c["id"] for c in client.get("/api/projects/ohm/collections").json()["items"]}
|
|
assert "secret" not in listed3
|
|
assert client.get("/api/projects/ohm/collections/secret").status_code == 404
|
|
|
|
|
|
def test_collection_visibility_strictness_validated_at_create(app_with_fake_gitea):
|
|
"""A collection may be created only as strict or stricter than its project;
|
|
a looser request is refused (422). On a gated project, a 'public' collection
|
|
is rejected."""
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
_project("locked", "gated")
|
|
_collection("locked", "locked", subfolder="", visibility="gated")
|
|
# eve is a deployment owner here to clear the create-authority gate;
|
|
# the strictness check fires regardless.
|
|
provision_user_row(user_id=9, login="root", role="owner")
|
|
sign_in_as(client, user_id=9, gitea_login="root", display_name="Root", role="owner")
|
|
r = client.post("/api/projects/locked/collections", json={
|
|
"collection_id": "wideopen", "type": "document", "visibility": "public",
|
|
})
|
|
assert r.status_code == 422, r.text
|
|
assert "looser" in r.json()["detail"]
|
|
|
|
|
|
def test_create_collection_allowed_for_project_owner_not_plain_contributor(app_with_fake_gitea):
|
|
"""§B.1: a project-scope Owner may create a collection; a plain granted
|
|
contributor with no project/global grant may not (403)."""
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
_seed_world()
|
|
# gil is only a project *contributor* on ohm — per §B.1 a project-scope
|
|
# contributor CAN create collections (the project-level create
|
|
# affordance). A collection-scope grant cannot.
|
|
sign_in_as(client, user_id=7, gitea_login="gil", display_name="Gil", role="contributor")
|
|
r_ok = client.post("/api/projects/ohm/collections", json={
|
|
"collection_id": "fromgil", "type": "document", "visibility": "public",
|
|
})
|
|
assert r_ok.status_code in (200, 502), r_ok.text # past the authz gate
|
|
|
|
# ada holds only a *collection*-scope grant (at model) — no create right.
|
|
sign_in_as(client, user_id=1, gitea_login="ada", display_name="Ada", role="contributor")
|
|
r_no = client.post("/api/projects/ohm/collections", json={
|
|
"collection_id": "fromada", "type": "document", "visibility": "public",
|
|
})
|
|
assert r_no.status_code == 403, r_no.text
|