feat: fine-grained capabilities and enterprise IAM schema extensions (#996)

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:
cybermaggedon 2026-06-22 20:23:34 +01:00 committed by GitHub
parent 8797d9d9ff
commit 09b8a1d347
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 147 additions and 18 deletions

View file

@ -5,6 +5,7 @@ from ...schema import (
UserInput, UserRecord,
WorkspaceInput, WorkspaceRecord,
ApiKeyInput, ApiKeyRecord,
GroupInput, GrantInput,
)
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):
if r is None:
return None
@ -102,6 +122,15 @@ class IamRequestTranslator(MessageTranslator):
data.get("workspace_record")
),
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]:
@ -109,6 +138,9 @@ class IamRequestTranslator(MessageTranslator):
for fname in (
"workspace", "actor", "user_id", "username", "key_id",
"api_key", "password", "new_password",
"group_id", "member_type", "member_id",
"capability", "resource_json", "parameters_json",
"authorise_checks",
):
v = getattr(obj, fname, "")
if v:
@ -135,6 +167,17 @@ class IamRequestTranslator(MessageTranslator):
"name": obj.key.name,
"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
@ -190,6 +233,23 @@ class IamResponseTranslator(MessageTranslator):
# setup, so it can't be dropped by a truthy-only filter.
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
def encode_with_completion(

View file

@ -74,6 +74,21 @@ class ApiKeyRecord:
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
class IamRequest:
operation: str = ""
@ -99,6 +114,13 @@ class IamRequest:
workspace_record: WorkspaceInput | 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 ----
# Capability string from the vocabulary in capabilities.md.
capability: str = ""
@ -164,6 +186,14 @@ class IamResponse:
# authorise_checks.
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

View file

@ -506,18 +506,18 @@ _FLOW_SERVICES = {
"text-completion": "llm",
"prompt": "llm",
"mcp-tool": "mcp",
"graph-rag": "graph:read",
"document-rag": "documents:read",
"graph-rag": "graph-rag:read",
"document-rag": "document-rag:read",
"embeddings": "embeddings",
"graph-embeddings": "graph:read",
"document-embeddings": "documents:read",
"triples": "graph:read",
"graph-embeddings": "graph-embeddings:read",
"document-embeddings": "document-embeddings:read",
"triples": "triples:read",
"rows": "rows:read",
"nlp-query": "rows:read",
"structured-query": "rows:read",
"structured-diag": "rows:read",
"row-embeddings": "rows:read",
"sparql": "graph:read",
"nlp-query": "nlp-query:read",
"structured-query": "structured-query:read",
"structured-diag": "structured-query:read",
"row-embeddings": "row-embeddings:read",
"sparql": "sparql:read",
}
for _kind, _cap in _FLOW_SERVICES.items():
_register_flow_kind("flow-service", _kind, _cap)
@ -525,10 +525,10 @@ for _kind, _cap in _FLOW_SERVICES.items():
# Streaming import socket endpoints.
_FLOW_IMPORTS = {
"triples": "graph:write",
"graph-embeddings": "graph:write",
"document-embeddings": "documents:write",
"entity-contexts": "documents:write",
"triples": "triples:write",
"graph-embeddings": "graph-embeddings:write",
"document-embeddings": "document-embeddings:write",
"entity-contexts": "entity-contexts:write",
"rows": "rows:write",
}
for _kind, _cap in _FLOW_IMPORTS.items():
@ -537,10 +537,35 @@ for _kind, _cap in _FLOW_IMPORTS.items():
# Streaming export socket endpoints.
_FLOW_EXPORTS = {
"triples": "graph:read",
"graph-embeddings": "graph:read",
"document-embeddings": "documents:read",
"entity-contexts": "documents:read",
"triples": "triples:read",
"graph-embeddings": "graph-embeddings:read",
"document-embeddings": "document-embeddings:read",
"entity-contexts": "entity-contexts:read",
}
for _kind, _cap in _FLOW_EXPORTS.items():
_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 = {
"agent",
"graph:read",
"triples:read",
"sparql:read",
"graph-rag:read",
"graph-embeddings:read",
"documents:read",
"document-rag:read",
"document-embeddings:read",
"entity-contexts:read",
"rows:read",
"nlp-query:read",
"structured-query:read",
"row-embeddings:read",
"llm",
"embeddings",
"mcp",
@ -73,6 +83,10 @@ _READER_CAPS = {
_WRITER_CAPS = _READER_CAPS | {
"graph:write",
"triples:write",
"graph-embeddings:write",
"document-embeddings:write",
"entity-contexts:write",
"documents:write",
"rows:write",
"collections:write",