diff --git a/docs/ACCESS_RBAC_MODEL.md b/docs/ACCESS_RBAC_MODEL.md index 8d9c1da..30171a8 100644 --- a/docs/ACCESS_RBAC_MODEL.md +++ b/docs/ACCESS_RBAC_MODEL.md @@ -1,6 +1,6 @@ # GovOPlaN RBAC And Resource-Access Model -**Updated:** 2026-07-09 +**Updated:** 2026-07-11 ## Authorization Equation @@ -254,6 +254,61 @@ space/folder/file ownership: External file connections and spaces are additionally constrained by connector policy and owner/group assignment in the files module. +## Resource Access Explanations + +The access module exposes a diagnostic endpoint for explaining why a principal +can see or operate on a concrete resource: + +```text +GET /api/v1/admin/access/resource-explanation +``` + +Required query values: + +| Field | Meaning | +| --- | --- | +| `user_id` | Tenant membership to explain. The current module UIs pass the signed-in user. | +| `resource_type` | Module-owned type such as `file`, `folder`, or `campaign`. | +| `resource_id` | Stable module resource identifier. | +| `action` | Permission/action being explained, for example `files:file:read`. | +| `tenant_id` | Optional tenant override for system/admin contexts. | + +The access module always contributes effective-scope provenance for the action. +Installed modules may add resource provenance by exposing a +`ResourceAccessExplanationProvider` through a capability consumed by access. +Providers should return only facts they own, using these provenance kinds: + +| Kind | Meaning | +| --- | --- | +| `resource` | The concrete resource or an explicit not-found result. | +| `owner` | Matching user/group ownership. | +| `share` | Matching explicit user/group/tenant share. | +| `policy` | Administrative bypass or policy-derived grant. | +| `role` / `right` | Scope and role provenance from access itself. | + +Files currently registers `files.access` and explains file assets plus folders. +Persisted folders use their database ID. Folder rows inferred from file paths use +a deterministic virtual ID: + +```text +virtual-folder:v1:::: +``` + +The files provider validates virtual folder IDs before returning provenance: the +tenant and owner must match the resource ID, and at least one active file must +exist below the normalized folder path. This keeps virtual folder explanations +stable without forcing every inferred tree node to become a stored folder row. + +Campaign currently registers `campaigns.access` and explains the campaign +ownership/sharing object itself. Finer-grained campaign sub-objects are tracked +separately in `govoplan-campaign#50` until their resource identifiers and access +rules are decided. + +Cross-user resource explanation is a policy feature, not a module-local UI +detail. Until `govoplan-policy#6` is resolved, module UIs should default to +current-user explanation and avoid importing access-admin user-picking +components. + ## Mail Servers | Scope | Meaning | diff --git a/src/govoplan_core/core/access.py b/src/govoplan_core/core/access.py index c5e19cb..5d4fb62 100644 --- a/src/govoplan_core/core/access.py +++ b/src/govoplan_core/core/access.py @@ -85,6 +85,9 @@ AccessProvenanceKind = Literal[ "identity", "account", "tenant_membership", + "resource", + "owner", + "share", "organization_unit", "function", "group", @@ -496,6 +499,20 @@ class ResourceAccessService(Protocol): ... +@runtime_checkable +class ResourceAccessExplanationProvider(Protocol): + def explain_resource_provenance( + self, + session: object, + principal: PrincipalRef, + *, + resource_type: str, + resource_id: str, + action: str, + ) -> Sequence[AccessDecisionProvenance]: + ... + + @runtime_checkable class AccessExplanationService(Protocol): def explain_scope_provenance( diff --git a/src/govoplan_core/core/files.py b/src/govoplan_core/core/files.py new file mode 100644 index 0000000..247045b --- /dev/null +++ b/src/govoplan_core/core/files.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from govoplan_core.core.access import ResourceAccessExplanationProvider + + +CAPABILITY_FILES_ACCESS = "files.access" + + +@runtime_checkable +class FileAccessProvider(ResourceAccessExplanationProvider, Protocol): + """Resource-level access explanation provider for Files-owned resources.""" diff --git a/tests/test_access_contracts.py b/tests/test_access_contracts.py index 73d8935..436c4d8 100644 --- a/tests/test_access_contracts.py +++ b/tests/test_access_contracts.py @@ -32,6 +32,7 @@ from govoplan_core.core.access import ( AccessDecisionProvenance, AccessExplanationService, AccessGovernanceMaterializer, + ResourceAccessExplanationProvider, AccessSemanticDirectory, AccessSubjectRef, AccountRef, @@ -75,6 +76,7 @@ from govoplan_core.core.campaigns import ( CampaignPolicyContextProvider, CampaignRetentionProvider, ) +from govoplan_core.core.files import CAPABILITY_FILES_ACCESS, FileAccessProvider from govoplan_core.core.modules import ModuleContext, ModuleManifest from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, OrganizationFunctionAssignmentRef from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, OrganizationDirectory as CoreOrganizationDirectory, OrganizationFunctionRef as CoreOrganizationFunctionRef, OrganizationUnitRef as CoreOrganizationUnitRef @@ -239,6 +241,24 @@ class _FakeResourceAccessService: return AccessDecision(allowed=True) +class _FakeResourceAccessExplanationProvider: + def explain_resource_provenance( + self, + session: object, + principal: PrincipalRef, + *, + resource_type: str, + resource_id: str, + action: str, + ): + del session, principal, resource_type, action + return (AccessDecisionProvenance(kind="resource", id=resource_id, label="Demo resource"),) + + +class _FakeFileAccessProvider(_FakeResourceAccessExplanationProvider): + pass + + class _FakeAccessExplanationService: provenance = ( AccessDecisionProvenance(kind="identity", id="identity-1", label="Demo Person"), @@ -481,6 +501,7 @@ class AccessContractTests(unittest.TestCase): self.assertEqual("access.administration", CAPABILITY_ACCESS_ADMINISTRATION) self.assertEqual("access.governanceMaterializer", CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER) self.assertEqual("campaigns.access", CAPABILITY_CAMPAIGNS_ACCESS) + self.assertEqual("files.access", CAPABILITY_FILES_ACCESS) self.assertEqual("campaigns.deliveryTasks", CAPABILITY_CAMPAIGNS_DELIVERY_TASKS) self.assertEqual("campaigns.mailPolicyContext", CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT) self.assertEqual("campaigns.policyContext", CAPABILITY_CAMPAIGNS_POLICY_CONTEXT) @@ -584,6 +605,8 @@ class AccessContractTests(unittest.TestCase): self.assertIsInstance(_FakeAccessSemanticDirectory(), AccessSemanticDirectory) self.assertIsInstance(_FakePermissionEvaluator(), PermissionEvaluator) self.assertIsInstance(_FakeResourceAccessService(), ResourceAccessService) + self.assertIsInstance(_FakeResourceAccessExplanationProvider(), ResourceAccessExplanationProvider) + self.assertIsInstance(_FakeFileAccessProvider(), FileAccessProvider) self.assertIsInstance(_FakeAccessExplanationService(), AccessExplanationService) self.assertIsInstance(_FakeTenantAccessProvisioner(), TenantAccessProvisioner) self.assertIsInstance(_FakeAccessAdministration(), AccessAdministration) @@ -1108,6 +1131,32 @@ class AccessContractTests(unittest.TestCase): idm_directory = FakeIdmDirectory() organization_directory = FakeOrganizationDirectory() + class FakeFileAccessProvider: + def explain_resource_provenance( + self, + session: object, + principal: PrincipalRef, + *, + resource_type: str, + resource_id: str, + action: str, + ): + del session, principal, action + if resource_type != "file": + return () + return ( + AccessDecisionProvenance( + kind="resource", + id=resource_id, + label="Registry.pdf", + tenant_id=tenant_id, + source="files.file", + details={"resource_type": "file"}, + ), + ) + + resource_provider = FakeFileAccessProvider() + with temporary_database(f"sqlite:///{root / 'test.db'}") as database: create_scope_tables(database.engine) Base.metadata.create_all(bind=database.engine) @@ -1165,6 +1214,7 @@ class AccessContractTests(unittest.TestCase): service = SqlAccessExplanationService( idm_directory=idm_directory, organization_directory=organization_directory, + resource_explanation_providers=(resource_provider,), ) provenance = service.explain_scope_provenance( PrincipalRef( @@ -1178,8 +1228,30 @@ class AccessContractTests(unittest.TestCase): ) self.assertTrue(any(item.kind == "function" and item.label == "Registry Clerk" for item in provenance)) self.assertTrue(any(item.kind == "role" and item.label == "File Reader" for item in provenance)) + resource_provenance = service.explain_resource_provenance( + PrincipalRef( + account_id=account_id, + membership_id=user_id, + tenant_id=tenant_id, + identity_id=identity_id, + scopes=frozenset({"files:file:read"}), + ), + resource_type="file", + resource_id="file-access-explanation", + action="files:file:read", + ) + self.assertTrue(any(item.kind == "resource" and item.id == "file-access-explanation" for item in resource_provenance)) + self.assertTrue(any(item.kind == "right" and item.id == "files:file:read" for item in resource_provenance)) registry = PlatformRegistry() + registry.register( + ModuleManifest( + id="access", + name="Access", + version="test", + capability_factories={CAPABILITY_ACCESS_EXPLANATION: lambda context: service}, + ) + ) registry.register( ModuleManifest( id="idm", @@ -1230,6 +1302,18 @@ class AccessContractTests(unittest.TestCase): self.assertIn("files:file:read", payload["user"]["effective_scopes"]) self.assertEqual("idm_function_role", payload["role_sources"][0]["source_type"]) self.assertEqual("Registry Clerk", payload["function_facts"][0]["function_name"]) + resource_response = client.get( + "/admin/access/resource-explanation", + params={ + "user_id": user_id, + "resource_type": "file", + "resource_id": "file-access-explanation", + "action": "files:file:read", + }, + ) + self.assertEqual(200, resource_response.status_code, resource_response.text) + resource_payload = resource_response.json() + self.assertTrue(any(item["kind"] == "resource" and item["id"] == "file-access-explanation" for item in resource_payload["provenance"])) finally: clear_runtime() shutil.rmtree(root, ignore_errors=True)