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
|
# GovOPlaN RBAC And Resource-Access Model
|
||||||
|
|
||||||
**Updated:** 2026-07-09
|
**Updated:** 2026-07-11
|
||||||
|
|
||||||
## Authorization Equation
|
## Authorization Equation
|
||||||
|
|
||||||
@@ -254,6 +254,61 @@ space/folder/file ownership:
|
|||||||
External file connections and spaces are additionally constrained by connector
|
External file connections and spaces are additionally constrained by connector
|
||||||
policy and owner/group assignment in the files module.
|
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
|
## Mail Servers
|
||||||
|
|
||||||
| Scope | Meaning |
|
| Scope | Meaning |
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ AccessProvenanceKind = Literal[
|
|||||||
"identity",
|
"identity",
|
||||||
"account",
|
"account",
|
||||||
"tenant_membership",
|
"tenant_membership",
|
||||||
|
"resource",
|
||||||
|
"owner",
|
||||||
|
"share",
|
||||||
"organization_unit",
|
"organization_unit",
|
||||||
"function",
|
"function",
|
||||||
"group",
|
"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
|
@runtime_checkable
|
||||||
class AccessExplanationService(Protocol):
|
class AccessExplanationService(Protocol):
|
||||||
def explain_scope_provenance(
|
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,
|
AccessDecisionProvenance,
|
||||||
AccessExplanationService,
|
AccessExplanationService,
|
||||||
AccessGovernanceMaterializer,
|
AccessGovernanceMaterializer,
|
||||||
|
ResourceAccessExplanationProvider,
|
||||||
AccessSemanticDirectory,
|
AccessSemanticDirectory,
|
||||||
AccessSubjectRef,
|
AccessSubjectRef,
|
||||||
AccountRef,
|
AccountRef,
|
||||||
@@ -75,6 +76,7 @@ from govoplan_core.core.campaigns import (
|
|||||||
CampaignPolicyContextProvider,
|
CampaignPolicyContextProvider,
|
||||||
CampaignRetentionProvider,
|
CampaignRetentionProvider,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.files import CAPABILITY_FILES_ACCESS, FileAccessProvider
|
||||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||||
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, OrganizationFunctionAssignmentRef
|
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
|
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)
|
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:
|
class _FakeAccessExplanationService:
|
||||||
provenance = (
|
provenance = (
|
||||||
AccessDecisionProvenance(kind="identity", id="identity-1", label="Demo Person"),
|
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.administration", CAPABILITY_ACCESS_ADMINISTRATION)
|
||||||
self.assertEqual("access.governanceMaterializer", CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER)
|
self.assertEqual("access.governanceMaterializer", CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER)
|
||||||
self.assertEqual("campaigns.access", CAPABILITY_CAMPAIGNS_ACCESS)
|
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.deliveryTasks", CAPABILITY_CAMPAIGNS_DELIVERY_TASKS)
|
||||||
self.assertEqual("campaigns.mailPolicyContext", CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT)
|
self.assertEqual("campaigns.mailPolicyContext", CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT)
|
||||||
self.assertEqual("campaigns.policyContext", CAPABILITY_CAMPAIGNS_POLICY_CONTEXT)
|
self.assertEqual("campaigns.policyContext", CAPABILITY_CAMPAIGNS_POLICY_CONTEXT)
|
||||||
@@ -584,6 +605,8 @@ class AccessContractTests(unittest.TestCase):
|
|||||||
self.assertIsInstance(_FakeAccessSemanticDirectory(), AccessSemanticDirectory)
|
self.assertIsInstance(_FakeAccessSemanticDirectory(), AccessSemanticDirectory)
|
||||||
self.assertIsInstance(_FakePermissionEvaluator(), PermissionEvaluator)
|
self.assertIsInstance(_FakePermissionEvaluator(), PermissionEvaluator)
|
||||||
self.assertIsInstance(_FakeResourceAccessService(), ResourceAccessService)
|
self.assertIsInstance(_FakeResourceAccessService(), ResourceAccessService)
|
||||||
|
self.assertIsInstance(_FakeResourceAccessExplanationProvider(), ResourceAccessExplanationProvider)
|
||||||
|
self.assertIsInstance(_FakeFileAccessProvider(), FileAccessProvider)
|
||||||
self.assertIsInstance(_FakeAccessExplanationService(), AccessExplanationService)
|
self.assertIsInstance(_FakeAccessExplanationService(), AccessExplanationService)
|
||||||
self.assertIsInstance(_FakeTenantAccessProvisioner(), TenantAccessProvisioner)
|
self.assertIsInstance(_FakeTenantAccessProvisioner(), TenantAccessProvisioner)
|
||||||
self.assertIsInstance(_FakeAccessAdministration(), AccessAdministration)
|
self.assertIsInstance(_FakeAccessAdministration(), AccessAdministration)
|
||||||
@@ -1108,6 +1131,32 @@ class AccessContractTests(unittest.TestCase):
|
|||||||
idm_directory = FakeIdmDirectory()
|
idm_directory = FakeIdmDirectory()
|
||||||
organization_directory = FakeOrganizationDirectory()
|
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:
|
with temporary_database(f"sqlite:///{root / 'test.db'}") as database:
|
||||||
create_scope_tables(database.engine)
|
create_scope_tables(database.engine)
|
||||||
Base.metadata.create_all(bind=database.engine)
|
Base.metadata.create_all(bind=database.engine)
|
||||||
@@ -1165,6 +1214,7 @@ class AccessContractTests(unittest.TestCase):
|
|||||||
service = SqlAccessExplanationService(
|
service = SqlAccessExplanationService(
|
||||||
idm_directory=idm_directory,
|
idm_directory=idm_directory,
|
||||||
organization_directory=organization_directory,
|
organization_directory=organization_directory,
|
||||||
|
resource_explanation_providers=(resource_provider,),
|
||||||
)
|
)
|
||||||
provenance = service.explain_scope_provenance(
|
provenance = service.explain_scope_provenance(
|
||||||
PrincipalRef(
|
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 == "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))
|
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 = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="access",
|
||||||
|
name="Access",
|
||||||
|
version="test",
|
||||||
|
capability_factories={CAPABILITY_ACCESS_EXPLANATION: lambda context: service},
|
||||||
|
)
|
||||||
|
)
|
||||||
registry.register(
|
registry.register(
|
||||||
ModuleManifest(
|
ModuleManifest(
|
||||||
id="idm",
|
id="idm",
|
||||||
@@ -1230,6 +1302,18 @@ class AccessContractTests(unittest.TestCase):
|
|||||||
self.assertIn("files:file:read", payload["user"]["effective_scopes"])
|
self.assertIn("files:file:read", payload["user"]["effective_scopes"])
|
||||||
self.assertEqual("idm_function_role", payload["role_sources"][0]["source_type"])
|
self.assertEqual("idm_function_role", payload["role_sources"][0]["source_type"])
|
||||||
self.assertEqual("Registry Clerk", payload["function_facts"][0]["function_name"])
|
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:
|
finally:
|
||||||
clear_runtime()
|
clear_runtime()
|
||||||
shutil.rmtree(root, ignore_errors=True)
|
shutil.rmtree(root, ignore_errors=True)
|
||||||
|
|||||||
Reference in New Issue
Block a user