154 lines
6.0 KiB
Python
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
|