§22 S3: scope-role enforcement + collection-grain visibility (@S3) — v0.42.0
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>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
"""Migration 030 — the global-scope grant (§22 three-tier S3).
|
||||
|
||||
Proves: `memberships.scope_type` now admits 'global' alongside 'project' and
|
||||
'collection' (§B.2's four-layer resolver), existing rows survive the rebuild,
|
||||
and the UNIQUE(scope_type, scope_id, user_id) shape is preserved.
|
||||
Template: test_migration_029_collections.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class _Cfg:
|
||||
def __init__(self, path):
|
||||
self.database_path = path
|
||||
|
||||
|
||||
def _fresh_db():
|
||||
d = tempfile.mkdtemp()
|
||||
path = Path(d) / "t.db"
|
||||
db.run_migrations(_Cfg(str(path)))
|
||||
return db.connect(str(path))
|
||||
|
||||
|
||||
def _add_user(conn, uid, login):
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, gitea_id, gitea_login, display_name, role) "
|
||||
"VALUES (?, ?, ?, ?, 'contributor')",
|
||||
(uid, uid, login, login.capitalize()),
|
||||
)
|
||||
|
||||
|
||||
def test_global_scope_type_is_accepted():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "cleo")
|
||||
# global grant — the new tier — is accepted.
|
||||
conn.execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('global', '*', 1, 'contributor')"
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT scope_type, scope_id, role FROM memberships WHERE user_id = 1"
|
||||
).fetchone()
|
||||
assert row["scope_type"] == "global"
|
||||
assert row["scope_id"] == "*"
|
||||
assert row["role"] == "contributor"
|
||||
|
||||
|
||||
def test_project_and_collection_scopes_still_accepted():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "ben")
|
||||
conn.execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('project', 'default', 1, 'owner')"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('collection', 'default', 1, 'contributor')"
|
||||
)
|
||||
n = conn.execute("SELECT COUNT(*) AS n FROM memberships WHERE user_id = 1").fetchone()["n"]
|
||||
assert n == 2
|
||||
|
||||
|
||||
def test_unknown_scope_type_still_rejected():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "x")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('deployment', '*', 1, 'owner')"
|
||||
)
|
||||
|
||||
|
||||
def test_one_global_grant_per_user():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "cleo")
|
||||
conn.execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('global', '*', 1, 'contributor')"
|
||||
)
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('global', '*', 1, 'owner')"
|
||||
)
|
||||
Reference in New Issue
Block a user