Add resource access explanation contract
Some checks failed
Dependency Audit / dependency-audit (push) Has been cancelled
Some checks failed
Dependency Audit / dependency-audit (push) Has been cancelled
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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(
|
||||
|
||||
13
src/govoplan_core/core/files.py
Normal file
13
src/govoplan_core/core/files.py
Normal 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."""
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user