feat: fine-grained capabilities and enterprise IAM schema extensions

Split coarse gateway capabilities into fine-grained variants to
support per-operation access control in the enterprise IAM regime.
Add additive schema fields for enterprise group and grant management.

Capability split (gateway registry):
- graph:read -> triples:read, sparql:read, graph-rag:read,
  graph-embeddings:read
- graph:write -> triples:write, graph-embeddings:write,
  entity-contexts:write
- documents:read -> documents:read, document-rag:read,
  document-embeddings:read, entity-contexts:read
- documents:write -> documents:write, document-embeddings:write
- rows:read -> rows:read, nlp-query:read, structured-query:read,
  row-embeddings:read

OSS role definitions expanded to include all new fine-grained
capability names — no behavioral change for OSS deployments.

Schema additions (IamRequest):
- group_id, member_type, member_id for group membership operations
- group (GroupInput), grant (GrantInput) for create/update payloads
- Decoder now handles capability, resource_json, parameters_json,
  authorise_checks (previously missing from translator)

Schema additions (IamResponse):
- group_json, groups_json, members_json, grants_json,
  effective_permissions_json for enterprise operation responses
- Encoder now emits authorise decision fields

Gateway registry:
- 16 enterprise IAM operations registered (create-group,
  add-group-member, add-user-grant, etc.) under iam:admin capability
This commit is contained in:
Cyber MacGeddon 2026-06-22 15:20:41 +01:00
parent 8797d9d9ff
commit 94ff37989c
4 changed files with 147 additions and 18 deletions

View file

@ -5,6 +5,7 @@ from ...schema import (
UserInput, UserRecord, UserInput, UserRecord,
WorkspaceInput, WorkspaceRecord, WorkspaceInput, WorkspaceRecord,
ApiKeyInput, ApiKeyRecord, ApiKeyInput, ApiKeyRecord,
GroupInput, GrantInput,
) )
from .base import MessageTranslator from .base import MessageTranslator
@ -43,6 +44,25 @@ def _api_key_input_from_dict(d):
) )
def _group_input_from_dict(d):
if d is None:
return None
return GroupInput(
name=d.get("name", ""),
description=d.get("description", ""),
enabled=d.get("enabled", True),
)
def _grant_input_from_dict(d):
if d is None:
return None
return GrantInput(
capability=d.get("capability", ""),
workspace=d.get("workspace", ""),
)
def _user_record_to_dict(r): def _user_record_to_dict(r):
if r is None: if r is None:
return None return None
@ -102,6 +122,15 @@ class IamRequestTranslator(MessageTranslator):
data.get("workspace_record") data.get("workspace_record")
), ),
key=_api_key_input_from_dict(data.get("key")), key=_api_key_input_from_dict(data.get("key")),
group_id=data.get("group_id", ""),
member_type=data.get("member_type", ""),
member_id=data.get("member_id", ""),
group=_group_input_from_dict(data.get("group")),
grant=_grant_input_from_dict(data.get("grant")),
capability=data.get("capability", ""),
resource_json=data.get("resource_json", ""),
parameters_json=data.get("parameters_json", ""),
authorise_checks=data.get("authorise_checks", ""),
) )
def encode(self, obj: IamRequest) -> Dict[str, Any]: def encode(self, obj: IamRequest) -> Dict[str, Any]:
@ -109,6 +138,9 @@ class IamRequestTranslator(MessageTranslator):
for fname in ( for fname in (
"workspace", "actor", "user_id", "username", "key_id", "workspace", "actor", "user_id", "username", "key_id",
"api_key", "password", "new_password", "api_key", "password", "new_password",
"group_id", "member_type", "member_id",
"capability", "resource_json", "parameters_json",
"authorise_checks",
): ):
v = getattr(obj, fname, "") v = getattr(obj, fname, "")
if v: if v:
@ -135,6 +167,17 @@ class IamRequestTranslator(MessageTranslator):
"name": obj.key.name, "name": obj.key.name,
"expires": obj.key.expires, "expires": obj.key.expires,
} }
if obj.group is not None:
result["group"] = {
"name": obj.group.name,
"description": obj.group.description,
"enabled": obj.group.enabled,
}
if obj.grant is not None:
result["grant"] = {
"capability": obj.grant.capability,
"workspace": obj.grant.workspace,
}
return result return result
@ -190,6 +233,23 @@ class IamResponseTranslator(MessageTranslator):
# setup, so it can't be dropped by a truthy-only filter. # setup, so it can't be dropped by a truthy-only filter.
result["bootstrap_available"] = bool(obj.bootstrap_available) result["bootstrap_available"] = bool(obj.bootstrap_available)
# authorise / authorise-many outputs.
if obj.decision_allow:
result["decision_allow"] = obj.decision_allow
if obj.decision_ttl_seconds:
result["decision_ttl_seconds"] = obj.decision_ttl_seconds
if obj.decisions_json:
result["decisions_json"] = obj.decisions_json
# Enterprise IAM outputs.
for fname in (
"group_json", "groups_json", "members_json",
"grants_json", "effective_permissions_json",
):
v = getattr(obj, fname, "")
if v:
result[fname] = v
return result return result
def encode_with_completion( def encode_with_completion(

View file

@ -74,6 +74,21 @@ class ApiKeyRecord:
last_used: str = "" last_used: str = ""
# ---- Enterprise IAM types (additive) ----
@dataclass
class GroupInput:
name: str = ""
description: str = ""
enabled: bool = True
@dataclass
class GrantInput:
capability: str = ""
workspace: str = ""
@dataclass @dataclass
class IamRequest: class IamRequest:
operation: str = "" operation: str = ""
@ -99,6 +114,13 @@ class IamRequest:
workspace_record: WorkspaceInput | None = None workspace_record: WorkspaceInput | None = None
key: ApiKeyInput | None = None key: ApiKeyInput | None = None
# ---- Enterprise IAM inputs (additive) ----
group_id: str = ""
member_type: str = ""
member_id: str = ""
group: GroupInput | None = None
grant: GrantInput | None = None
# ---- authorise / authorise-many inputs ---- # ---- authorise / authorise-many inputs ----
# Capability string from the vocabulary in capabilities.md. # Capability string from the vocabulary in capabilities.md.
capability: str = "" capability: str = ""
@ -164,6 +186,14 @@ class IamResponse:
# authorise_checks. # authorise_checks.
decisions_json: str = "" decisions_json: str = ""
# ---- Enterprise IAM outputs (additive) ----
# JSON-serialised payloads for enterprise group/grant operations.
group_json: str = ""
groups_json: str = ""
members_json: str = ""
grants_json: str = ""
effective_permissions_json: str = ""
error: Error | None = None error: Error | None = None

View file

@ -506,18 +506,18 @@ _FLOW_SERVICES = {
"text-completion": "llm", "text-completion": "llm",
"prompt": "llm", "prompt": "llm",
"mcp-tool": "mcp", "mcp-tool": "mcp",
"graph-rag": "graph:read", "graph-rag": "graph-rag:read",
"document-rag": "documents:read", "document-rag": "document-rag:read",
"embeddings": "embeddings", "embeddings": "embeddings",
"graph-embeddings": "graph:read", "graph-embeddings": "graph-embeddings:read",
"document-embeddings": "documents:read", "document-embeddings": "document-embeddings:read",
"triples": "graph:read", "triples": "triples:read",
"rows": "rows:read", "rows": "rows:read",
"nlp-query": "rows:read", "nlp-query": "nlp-query:read",
"structured-query": "rows:read", "structured-query": "structured-query:read",
"structured-diag": "rows:read", "structured-diag": "structured-query:read",
"row-embeddings": "rows:read", "row-embeddings": "row-embeddings:read",
"sparql": "graph:read", "sparql": "sparql:read",
} }
for _kind, _cap in _FLOW_SERVICES.items(): for _kind, _cap in _FLOW_SERVICES.items():
_register_flow_kind("flow-service", _kind, _cap) _register_flow_kind("flow-service", _kind, _cap)
@ -525,10 +525,10 @@ for _kind, _cap in _FLOW_SERVICES.items():
# Streaming import socket endpoints. # Streaming import socket endpoints.
_FLOW_IMPORTS = { _FLOW_IMPORTS = {
"triples": "graph:write", "triples": "triples:write",
"graph-embeddings": "graph:write", "graph-embeddings": "graph-embeddings:write",
"document-embeddings": "documents:write", "document-embeddings": "document-embeddings:write",
"entity-contexts": "documents:write", "entity-contexts": "entity-contexts:write",
"rows": "rows:write", "rows": "rows:write",
} }
for _kind, _cap in _FLOW_IMPORTS.items(): for _kind, _cap in _FLOW_IMPORTS.items():
@ -537,10 +537,35 @@ for _kind, _cap in _FLOW_IMPORTS.items():
# Streaming export socket endpoints. # Streaming export socket endpoints.
_FLOW_EXPORTS = { _FLOW_EXPORTS = {
"triples": "graph:read", "triples": "triples:read",
"graph-embeddings": "graph:read", "graph-embeddings": "graph-embeddings:read",
"document-embeddings": "documents:read", "document-embeddings": "document-embeddings:read",
"entity-contexts": "documents:read", "entity-contexts": "entity-contexts:read",
} }
for _kind, _cap in _FLOW_EXPORTS.items(): for _kind, _cap in _FLOW_EXPORTS.items():
_register_flow_kind("flow-export", _kind, _cap) _register_flow_kind("flow-export", _kind, _cap)
# ---------------------------------------------------------------------------
# Enterprise IAM operations.
#
# These are additive — they register alongside the OSS IAM operations.
# When the OSS regime receives an unknown operation it returns an error;
# when the enterprise regime is running, it handles them.
# ---------------------------------------------------------------------------
for _op in (
"create-group", "get-group", "list-groups",
"update-group", "delete-group",
"add-group-member", "remove-group-member", "list-group-members",
"add-group-grant", "remove-group-grant", "list-group-grants",
"add-user-grant", "remove-user-grant", "list-user-grants",
"resolve-effective-permissions",
):
register(Operation(
name=_op,
capability="iam:admin",
resource_level=ResourceLevel.SYSTEM,
extract_resource=_empty_resource,
extract_parameters=_no_parameters,
))

View file

@ -58,8 +58,18 @@ AUTHZ_CACHE_TTL_SECONDS = 60
_READER_CAPS = { _READER_CAPS = {
"agent", "agent",
"graph:read", "graph:read",
"triples:read",
"sparql:read",
"graph-rag:read",
"graph-embeddings:read",
"documents:read", "documents:read",
"document-rag:read",
"document-embeddings:read",
"entity-contexts:read",
"rows:read", "rows:read",
"nlp-query:read",
"structured-query:read",
"row-embeddings:read",
"llm", "llm",
"embeddings", "embeddings",
"mcp", "mcp",
@ -73,6 +83,10 @@ _READER_CAPS = {
_WRITER_CAPS = _READER_CAPS | { _WRITER_CAPS = _READER_CAPS | {
"graph:write", "graph:write",
"triples:write",
"graph-embeddings:write",
"document-embeddings:write",
"entity-contexts:write",
"documents:write", "documents:write",
"rows:write", "rows:write",
"collections:write", "collections:write",