Add resource access explanation contract
Some checks failed
Dependency Audit / dependency-audit (push) Has been cancelled

This commit is contained in:
2026-07-11 00:39:39 +02:00
parent c32951393a
commit 060f4da751
4 changed files with 170 additions and 1 deletions

View File

@@ -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:<tenant_id>:<owner_type>:<owner_id>:<base64url(normalized_path)>
```
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 |

View File

@@ -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(

View File

@@ -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."""

View File

@@ -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)