OSVauco/opax-mcp/policy/authority.py

154 lines
6.0 KiB
Python

"""
Provides a static, file-based authority for workspace membership.
This module loads and validates a JSON file defining members of a single workspace.
It is intended for an initial, internal-only launch and does not provide
multi-workspace support.
"""
import json
import pathlib
from dataclasses import dataclass
from types import MappingProxyType
from typing import Dict, List, Literal, Mapping, Optional
# --- Public API ---
class MembershipAuthorityError(Exception):
"""Custom exception for errors related to the membership authority."""
pass
@dataclass(frozen=True)
class MemberContext:
"""Represents the validated context of an authorized member."""
principal_id: str
workspace_id: str
role: Literal["admin", "operator", "member"]
status: Literal["active", "suspended", "revoked"]
# --- Internal Implementation ---
@dataclass(frozen=True)
class _MembershipAuthority:
"""Represents the entire validated membership authority configuration."""
schema_version: int
workspace_id: str
members: Mapping[str, MemberContext]
def _validate_authority_data(data: dict) -> _MembershipAuthority:
"""Performs strict validation of the raw data from JSON."""
_EXPECTED_TOP_LEVEL_KEYS = {"schema_version", "workspace_id", "members"}
_EXPECTED_MEMBER_KEYS = {"principal_id", "workspace_id", "role", "status"}
_VALID_ROLES = {"admin", "operator", "member"}
_VALID_STATUSES = {"active", "suspended", "revoked"}
if not isinstance(data, dict) or set(data.keys()) != _EXPECTED_TOP_LEVEL_KEYS:
raise MembershipAuthorityError(f"Authority data must be a JSON object with exact keys: {_EXPECTED_TOP_LEVEL_KEYS}")
schema_version = data["schema_version"]
if type(schema_version) is not int or schema_version != 1:
raise MembershipAuthorityError(f"Unsupported schema_version: must be integer 1.")
workspace_id = data.get("workspace_id")
if not isinstance(workspace_id, str) or not workspace_id.strip():
raise MembershipAuthorityError("'workspace_id' must be a non-empty string.")
if not isinstance(data.get("members"), list):
raise MembershipAuthorityError("'members' must be a list.")
members_map: Dict[str, MemberContext] = {}
for member_data in data["members"]:
if not isinstance(member_data, dict) or set(member_data.keys()) != _EXPECTED_MEMBER_KEYS:
raise MembershipAuthorityError(f"Member data must be an object with exact keys: {_EXPECTED_MEMBER_KEYS}")
principal_id = member_data.get("principal_id")
prefix = "user:google:"
if (not isinstance(principal_id, str) or not principal_id.strip() or
not principal_id.startswith(prefix)):
raise MembershipAuthorityError(f"Invalid principal_id format for '{principal_id}'.")
suffix = principal_id[len(prefix):]
if not suffix.strip():
raise MembershipAuthorityError(f"Invalid principal_id format for '{principal_id}': empty suffix.")
if principal_id in members_map:
raise MembershipAuthorityError(f"Duplicate principal_id found: {principal_id}")
if member_data.get("workspace_id") != workspace_id:
raise MembershipAuthorityError(f"Member workspace_id mismatch for {principal_id}.")
role = member_data.get("role")
if not isinstance(role, str) or role not in _VALID_ROLES:
raise MembershipAuthorityError(f"Invalid role for {principal_id}: {role}")
status = member_data.get("status")
if not isinstance(status, str) or status not in _VALID_STATUSES:
raise MembershipAuthorityError(f"Invalid status for {principal_id}: {status}")
members_map[principal_id] = MemberContext(**member_data)
return _MembershipAuthority(
schema_version=schema_version,
workspace_id=workspace_id,
members=MappingProxyType(members_map),
)
# --- Public API Functions ---
def load_membership_authority(
authority_path: Optional[pathlib.Path] = None,
) -> _MembershipAuthority:
"""
Loads and validates the membership_authority.json file.
Args:
authority_path: Optional path to the authority file for testing.
Returns:
A validated, internal _MembershipAuthority object.
Raises:
MembershipAuthorityError: If the file is not found, malformed, or fails validation.
"""
if authority_path is None:
authority_path = pathlib.Path(__file__).parent / "membership_authority.json"
try:
with open(authority_path, "r", encoding="utf-8") as f:
raw_data = json.load(f)
except FileNotFoundError as e:
raise MembershipAuthorityError(f"Authority file not found at {authority_path}") from e
except OSError as e:
raise MembershipAuthorityError(f"Authority file could not be read at {authority_path}") from e
except UnicodeDecodeError as e:
raise MembershipAuthorityError(f"Authority file is not valid UTF-8 at {authority_path}") from e
except json.JSONDecodeError as e:
raise MembershipAuthorityError(f"Authority file is not valid JSON: {e}") from e
return _validate_authority_data(raw_data)
def get_member_context_by_principal(
principal_id: str, authority: Optional[_MembershipAuthority] = None
) -> Optional[MemberContext]:
"""
Retrieves the context for a given principal_id.
If authority is not provided, it loads from the default module-relative file.
An invalid authority file will raise MembershipAuthorityError.
Returns a MemberContext only if the principal exists and their status is 'active'.
Returns None for unknown, suspended, or revoked principals.
Args:
principal_id: The canonical principal ID to look up.
authority: Optional pre-loaded authority object for testing.
Returns:
A MemberContext object or None.
"""
auth_to_check = authority
if auth_to_check is None:
auth_to_check = load_membership_authority()
member = auth_to_check.members.get(principal_id)
if member and member.status == "active":
return member
return None