initial commit after split

This commit is contained in:
2026-06-24 01:43:10 +02:00
parent b1d6c0150f
commit 30c11a6dcf
173 changed files with 25380 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from sqlalchemy.orm import Session
from govoplan_core.db.models import ApiKey, User
from govoplan_core.access.auth.tokens import generate_secret, hash_secret, verify_secret
from govoplan_core.security.time import ensure_aware_utc, utc_now
API_KEY_PREFIX_LENGTH = 12
API_KEY_RANDOM_BYTES = 32
@dataclass(slots=True)
class CreatedApiKey:
model: ApiKey
secret: str
def hash_api_key(secret: str) -> str:
return hash_secret(secret)
def verify_api_key(secret: str, expected_hash: str) -> bool:
return verify_secret(secret, expected_hash)
def generate_api_key_secret() -> str:
return generate_secret("mm_", random_bytes=API_KEY_RANDOM_BYTES)
def api_key_prefix(secret: str) -> str:
# Prefix is only a lookup helper and must not be enough to authenticate.
return secret[:API_KEY_PREFIX_LENGTH]
def create_api_key(
session: Session,
*,
user: User,
name: str,
scopes: list[str],
secret: str | None = None,
expires_at: datetime | None = None,
) -> CreatedApiKey:
secret = secret or generate_api_key_secret()
model = ApiKey(
tenant_id=user.tenant_id,
user_id=user.id,
name=name,
prefix=api_key_prefix(secret),
key_hash=hash_api_key(secret),
scopes=scopes,
expires_at=expires_at,
)
session.add(model)
session.flush()
return CreatedApiKey(model=model, secret=secret)
def authenticate_api_key(session: Session, secret: str) -> ApiKey | None:
prefix = api_key_prefix(secret)
candidates = session.query(ApiKey).filter(ApiKey.prefix == prefix, ApiKey.revoked_at.is_(None)).all()
now = utc_now()
for candidate in candidates:
expires_at = ensure_aware_utc(candidate.expires_at)
if expires_at and expires_at < now:
continue
if verify_api_key(secret, candidate.key_hash):
candidate.last_used_at = now
session.add(candidate)
return candidate
return None
def has_scope(api_key: ApiKey, required_scope: str) -> bool:
scopes = set(api_key.scopes or [])
return "*" in scopes or required_scope in scopes
@@ -0,0 +1,68 @@
from __future__ import annotations
from collections.abc import Iterable
from govoplan_core.security.permissions import scopes_grant
LEGACY_TO_MODULE_SCOPES: dict[str, str] = {
"campaign:read": "campaigns:campaign:read",
"campaign:create": "campaigns:campaign:create",
"campaign:update": "campaigns:campaign:update",
"campaign:copy": "campaigns:campaign:copy",
"campaign:archive": "campaigns:campaign:archive",
"campaign:delete": "campaigns:campaign:delete",
"campaign:share": "campaigns:campaign:share",
"campaign:validate": "campaigns:campaign:validate",
"campaign:build": "campaigns:campaign:build",
"campaign:review": "campaigns:campaign:review",
"campaign:send_test": "campaigns:campaign:send_test",
"campaign:queue": "campaigns:campaign:queue",
"campaign:control": "campaigns:campaign:control",
"campaign:send": "campaigns:campaign:send",
"campaign:retry": "campaigns:campaign:retry",
"campaign:reconcile": "campaigns:campaign:reconcile",
"recipients:read": "campaigns:recipient:read",
"recipients:write": "campaigns:recipient:write",
"recipients:import": "campaigns:recipient:import",
"recipients:export": "campaigns:recipient:export",
"reports:read": "campaigns:report:read",
"reports:export": "campaigns:report:export",
"reports:send": "campaigns:report:send",
"files:read": "files:file:read",
"files:download": "files:file:download",
"files:upload": "files:file:upload",
"files:organize": "files:file:organize",
"files:share": "files:file:share",
"files:delete": "files:file:delete",
"files:admin": "files:file:admin",
"mail_servers:read": "mail:profile:read",
"mail_servers:use": "mail:profile:use",
"mail_servers:test": "mail:profile:test",
"mail_servers:write": "mail:profile:write",
"mail_servers:manage_credentials": "mail:secret:manage",
}
MODULE_TO_LEGACY_SCOPES = {module: legacy for legacy, module in LEGACY_TO_MODULE_SCOPES.items()}
MODULE_TENANT_SCOPES = frozenset(LEGACY_TO_MODULE_SCOPES.values())
def compatible_required_scopes(required: str) -> tuple[str, ...]:
legacy = MODULE_TO_LEGACY_SCOPES.get(required)
if legacy:
return (required, legacy)
module = LEGACY_TO_MODULE_SCOPES.get(required)
if module:
return (required, module)
return (required,)
def scopes_grant_compatible(scopes: Iterable[str], required: str) -> bool:
granted = list(scopes)
if scopes_grant(granted, required):
return True
if "tenant:*" in granted or "*" in granted:
if required in MODULE_TENANT_SCOPES:
return True
return any(scopes_grant(granted, alias) for alias in compatible_required_scopes(required) if alias != required)
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import os
_ALGORITHM = "pbkdf2_sha256"
_DEFAULT_ITERATIONS = 260_000
_SALT_BYTES = 16
def hash_password(password: str, *, iterations: int = _DEFAULT_ITERATIONS) -> str:
salt = os.urandom(_SALT_BYTES)
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
return "$".join([
_ALGORITHM,
str(iterations),
base64.b64encode(salt).decode("ascii"),
base64.b64encode(digest).decode("ascii"),
])
def verify_password(password: str, encoded: str | None) -> bool:
if not encoded:
return False
try:
algorithm, iterations_text, salt_b64, digest_b64 = encoded.split("$", 3)
if algorithm != _ALGORITHM:
return False
iterations = int(iterations_text)
salt = base64.b64decode(salt_b64.encode("ascii"))
expected = base64.b64decode(digest_b64.encode("ascii"))
except Exception:
return False
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
return hmac.compare_digest(actual, expected)
+299
View File
@@ -0,0 +1,299 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable
@dataclass(frozen=True, slots=True)
class PermissionDefinition:
scope: str
label: str
description: str
category: str
level: str = "tenant"
TENANT_PERMISSIONS: tuple[PermissionDefinition, ...] = (
PermissionDefinition("campaign:read", "View campaigns", "Open campaign metadata, versions and permitted message summaries.", "Campaigns"),
PermissionDefinition("campaign:create", "Create campaigns", "Create new campaigns in an accessible tenant scope.", "Campaigns"),
PermissionDefinition("campaign:update", "Edit campaigns", "Edit current working campaign versions.", "Campaigns"),
PermissionDefinition("campaign:copy", "Copy campaigns", "Create a new campaign or working version from an existing campaign.", "Campaigns"),
PermissionDefinition("campaign:archive", "Archive campaigns", "Archive campaigns without destroying audit evidence.", "Campaigns"),
PermissionDefinition("campaign:delete", "Delete campaigns", "Delete draft-only campaigns where retention policy allows it.", "Campaigns"),
PermissionDefinition("campaign:share", "Share campaigns", "Grant or revoke explicit campaign access for users and groups.", "Campaigns"),
PermissionDefinition("campaign:validate", "Validate campaigns", "Run technical validation and manage validation locks.", "Campaigns"),
PermissionDefinition("campaign:build", "Build campaigns", "Build exact messages and attachment evidence.", "Campaigns"),
PermissionDefinition("campaign:review", "Approve campaign review", "Approve or reject built messages and non-blocking review conditions.", "Campaigns"),
PermissionDefinition("campaign:send_test", "Mock-send campaigns", "Use mock delivery and verification tools.", "Campaigns"),
PermissionDefinition("campaign:queue", "Queue campaigns", "Place approved executions into the delivery queue.", "Campaigns"),
PermissionDefinition("campaign:control", "Control delivery", "Pause, resume or cancel queued/sending jobs.", "Campaigns"),
PermissionDefinition("campaign:send", "Send campaigns", "Start real SMTP delivery.", "Campaigns"),
PermissionDefinition("campaign:retry", "Retry delivery", "Retry failed or unattempted delivery jobs.", "Campaigns"),
PermissionDefinition("campaign:reconcile", "Reconcile uncertain delivery", "Resolve outcome-unknown SMTP attempts after operational inspection.", "Campaigns"),
PermissionDefinition("recipients:read", "View recipients", "Read recipient lists and recipient-specific campaign data.", "Recipients"),
PermissionDefinition("recipients:write", "Edit recipients", "Create and edit recipient rows and recipient field values.", "Recipients"),
PermissionDefinition("recipients:import", "Import recipients", "Bulk-import recipient lists from files or reusable lists.", "Recipients"),
PermissionDefinition("recipients:export", "Export recipients", "Export recipient data or recipient-complete reports.", "Recipients"),
PermissionDefinition("files:read", "View files", "List, search and preview managed files.", "Files"),
PermissionDefinition("files:download", "Download files", "Download managed files and generated ZIP archives.", "Files"),
PermissionDefinition("files:upload", "Upload files", "Upload new managed file versions.", "Files"),
PermissionDefinition("files:organize", "Organize files", "Create folders, rename, move or copy managed files.", "Files"),
PermissionDefinition("files:share", "Share files", "Grant or revoke managed file shares.", "Files"),
PermissionDefinition("files:delete", "Delete files", "Delete or hide managed files and folders where retention policy allows it.", "Files"),
PermissionDefinition("files:admin", "Administer file spaces", "Access and administer all user and group file spaces in the tenant.", "Files"),
PermissionDefinition("reports:read", "View reports", "View campaign delivery reports and aggregate outcome data.", "Reports and audit"),
PermissionDefinition("reports:export", "Export reports", "Download detailed campaign reports such as CSV exports.", "Reports and audit"),
PermissionDefinition("reports:send", "Send reports", "Email campaign reports to configured recipients.", "Reports and audit"),
PermissionDefinition("audit:read", "View audit log", "Read tenant and campaign audit records.", "Reports and audit"),
PermissionDefinition("mail_servers:read", "View mail servers", "Inspect approved reusable SMTP/IMAP server profile metadata.", "Mail servers"),
PermissionDefinition("mail_servers:use", "Use mail servers", "Select an approved mail-server profile for a campaign without reading secrets.", "Mail servers"),
PermissionDefinition("mail_servers:test", "Test mail servers", "Run SMTP/IMAP connection tests.", "Mail servers"),
PermissionDefinition("mail_servers:write", "Manage mail servers", "Create and edit reusable mail-server profiles.", "Mail servers"),
PermissionDefinition("mail_servers:manage_credentials", "Manage mail credentials", "Create or replace stored SMTP/IMAP credentials.", "Mail servers"),
PermissionDefinition("admin:users:read", "View users", "List tenant users and their effective access.", "Tenant administration"),
PermissionDefinition("admin:users:create", "Create users", "Create tenant memberships for accounts.", "Tenant administration"),
PermissionDefinition("admin:users:update", "Update users", "Edit non-access membership metadata.", "Tenant administration"),
PermissionDefinition("admin:users:suspend", "Suspend users", "Activate or suspend tenant memberships.", "Tenant administration"),
PermissionDefinition("admin:groups:read", "View groups", "List tenant groups, members and assigned roles.", "Tenant administration"),
PermissionDefinition("admin:groups:write", "Manage groups", "Create or edit tenant group definitions.", "Tenant administration"),
PermissionDefinition("admin:groups:manage_members", "Manage group members", "Add and remove users from tenant groups.", "Tenant administration"),
PermissionDefinition("admin:roles:read", "View roles", "Inspect role definitions and the permission catalogue.", "Tenant administration"),
PermissionDefinition("admin:roles:write", "Define roles", "Create and edit assignable tenant role definitions.", "Tenant administration"),
PermissionDefinition("admin:roles:assign", "Assign roles", "Assign tenant roles to users or groups within delegation limits.", "Tenant administration"),
PermissionDefinition("admin:api_keys:read", "View API keys", "List tenant API keys without revealing their secret.", "Tenant administration"),
PermissionDefinition("admin:api_keys:create", "Create API keys", "Create tenant-local API keys within the owner's delegation limits.", "Tenant administration"),
PermissionDefinition("admin:api_keys:revoke", "Revoke API keys", "Revoke tenant-local API keys.", "Tenant administration"),
PermissionDefinition("admin:settings:read", "View tenant settings", "Read tenant defaults and non-policy settings.", "Tenant administration"),
PermissionDefinition("admin:settings:write", "Manage tenant settings", "Change tenant defaults and non-policy settings.", "Tenant administration"),
PermissionDefinition("admin:policies:read", "View tenant policies", "Read tenant policy and governance settings.", "Tenant administration"),
PermissionDefinition("admin:policies:write", "Manage tenant policies", "Change tenant policy and governance settings where system policy permits it.", "Tenant administration"),
)
SYSTEM_PERMISSIONS: tuple[PermissionDefinition, ...] = (
PermissionDefinition("system:tenants:read", "View all tenants", "List tenants and system-wide membership counts.", "System administration", "system"),
PermissionDefinition("system:tenants:create", "Create tenants", "Create new tenant spaces.", "System administration", "system"),
PermissionDefinition("system:tenants:update", "Update tenants", "Edit tenant metadata and governance overrides.", "System administration", "system"),
PermissionDefinition("system:tenants:suspend", "Suspend tenants", "Activate or suspend tenant spaces while preserving evidence.", "System administration", "system"),
PermissionDefinition("system:accounts:read", "View accounts", "List global login accounts and memberships.", "System administration", "system"),
PermissionDefinition("system:accounts:create", "Create accounts", "Create global login accounts.", "System administration", "system"),
PermissionDefinition("system:accounts:update", "Update accounts", "Edit global account metadata.", "System administration", "system"),
PermissionDefinition("system:accounts:suspend", "Suspend accounts", "Activate or suspend global login accounts while preserving a system owner.", "System administration", "system"),
PermissionDefinition("system:roles:read", "View system roles", "Inspect instance-wide role definitions and their permissions.", "System administration", "system"),
PermissionDefinition("system:roles:write", "Define system roles", "Create and edit instance-wide role definitions within delegation limits.", "System administration", "system"),
PermissionDefinition("system:roles:assign", "Assign system roles", "Assign instance-wide roles to accounts while preserving a system owner.", "System administration", "system"),
PermissionDefinition("system:access:read", "View system access (legacy)", "Compatibility scope for listing accounts with instance-wide roles.", "System administration", "system"),
PermissionDefinition("system:access:assign", "Assign system access (legacy)", "Compatibility scope for assigning instance-wide roles.", "System administration", "system"),
PermissionDefinition("system:audit:read", "View system audit", "Read audit records across tenants.", "System administration", "system"),
PermissionDefinition("system:settings:read", "View system settings", "Read instance defaults and tenant-governance defaults.", "System administration", "system"),
PermissionDefinition("system:settings:write", "Manage system settings", "Change instance defaults and tenant-governance defaults.", "System administration", "system"),
PermissionDefinition("system:governance:read", "View governance templates", "Inspect centrally managed group and role definitions.", "System administration", "system"),
PermissionDefinition("system:governance:write", "Manage governance templates", "Create and assign centrally managed group and role definitions.", "System administration", "system"),
)
ALL_PERMISSIONS: tuple[PermissionDefinition, ...] = TENANT_PERMISSIONS + SYSTEM_PERMISSIONS
KNOWN_SCOPES = frozenset(item.scope for item in ALL_PERMISSIONS)
TENANT_SCOPES = frozenset(item.scope for item in TENANT_PERMISSIONS)
SYSTEM_SCOPES = frozenset(item.scope for item in SYSTEM_PERMISSIONS)
LEGACY_SCOPE_ALIASES: dict[str, frozenset[str]] = {
# Only names that are no longer canonical remain runtime aliases. Canonical
# permissions keep their narrow meaning; the Alembic migration expands old
# role records once so upgraded installations do not lose prior access.
"campaign:write": frozenset({
"campaign:create", "campaign:update", "campaign:copy", "campaign:archive", "campaign:delete", "campaign:share",
"recipients:read", "recipients:write", "recipients:import",
}),
"attachments:read": frozenset({"files:read", "files:download"}),
"attachments:write": frozenset({"files:upload", "files:organize", "files:share", "files:delete"}),
"admin:users": frozenset({
"admin:users:read", "admin:users:create", "admin:users:update", "admin:users:suspend",
"admin:groups:read", "admin:groups:write", "admin:groups:manage_members",
"admin:roles:read", "admin:roles:write", "admin:roles:assign",
}),
"admin:users:write": frozenset({"admin:users:create", "admin:users:update", "admin:users:suspend", "admin:roles:assign", "admin:groups:manage_members"}),
"admin:api_keys:write": frozenset({"admin:api_keys:create", "admin:api_keys:revoke"}),
"admin:settings": frozenset({"admin:settings:read", "admin:settings:write", "admin:api_keys:read", "admin:api_keys:create", "admin:api_keys:revoke"}),
"system:tenants:write": frozenset({"system:tenants:create", "system:tenants:update", "system:tenants:suspend"}),
"system:access:write": frozenset({"system:access:assign", "system:roles:assign", "system:accounts:create", "system:accounts:update", "system:accounts:suspend"}),
}
DEFAULT_TENANT_ROLES: dict[str, dict[str, object]] = {
"owner": {"name": "Owner", "description": "Full tenant access, including administration and delivery.", "permissions": ["tenant:*"], "is_builtin": True, "is_assignable": True},
"tenant_admin": {"name": "Tenant administrator", "description": "Manage tenant settings, users, groups and roles. Real delivery remains separately delegable.", "permissions": [
"campaign:read", "files:read", "reports:read", "audit:read",
"admin:users:read", "admin:users:create", "admin:users:update", "admin:users:suspend",
"admin:groups:read", "admin:groups:write", "admin:groups:manage_members",
"admin:roles:read", "admin:roles:write", "admin:roles:assign",
"admin:api_keys:read", "admin:api_keys:create", "admin:api_keys:revoke",
"admin:settings:read", "admin:settings:write", "admin:policies:read", "admin:policies:write",
], "is_builtin": True, "is_assignable": True},
"admin": {"name": "Administrator (legacy)", "description": "Legacy broad tenant role retained for upgraded installations.", "permissions": sorted(TENANT_SCOPES), "is_builtin": True, "is_assignable": True},
"access_admin": {"name": "Access administrator", "description": "Manage memberships and assignments within delegation limits.", "permissions": [
"admin:users:read", "admin:users:create", "admin:users:update", "admin:users:suspend",
"admin:groups:read", "admin:groups:manage_members", "admin:roles:read", "admin:roles:assign",
], "is_builtin": True, "is_assignable": True},
"campaign_manager": {"name": "Campaign manager", "description": "Prepare, validate and build campaigns, but do not approve or start real delivery.", "permissions": [
"campaign:read", "campaign:create", "campaign:update", "campaign:copy", "campaign:validate", "campaign:build",
"recipients:read", "recipients:write", "recipients:import", "files:read", "files:download", "files:upload", "files:organize", "reports:read",
], "is_builtin": True, "is_assignable": True},
"reviewer": {"name": "Reviewer", "description": "Inspect and approve prepared campaign messages.", "permissions": ["campaign:read", "campaign:validate", "campaign:review", "recipients:read", "files:read", "reports:read"], "is_builtin": True, "is_assignable": True},
"sender": {"name": "Sender", "description": "Queue, test, control, send and reconcile prepared campaigns.", "permissions": [
"campaign:read", "campaign:send_test", "campaign:queue", "campaign:control", "campaign:send", "campaign:retry", "campaign:reconcile",
"recipients:read", "files:read", "reports:read", "reports:send", "mail_servers:use", "mail_servers:test",
], "is_builtin": True, "is_assignable": True},
"file_manager": {"name": "File manager", "description": "Manage tenant file spaces without campaign delivery rights.", "permissions": ["files:read", "files:download", "files:upload", "files:organize", "files:share", "files:delete"], "is_builtin": True, "is_assignable": True},
"viewer": {"name": "Viewer", "description": "Read campaigns, recipient data, files and reports without changing them.", "permissions": ["campaign:read", "recipients:read", "files:read", "reports:read"], "is_builtin": True, "is_assignable": True},
"auditor": {"name": "Auditor", "description": "Read campaigns, recipient evidence, reports and audit records.", "permissions": ["campaign:read", "recipients:read", "recipients:export", "reports:read", "reports:export", "audit:read"], "is_builtin": True, "is_assignable": True},
}
DEFAULT_SYSTEM_ROLES: dict[str, dict[str, object]] = {
"system_owner": {
"name": "System owner",
"description": "Protected full instance-wide administration. At least one active account must retain this role.",
"permissions": ["system:*"],
"is_builtin": True,
"is_assignable": True,
"managed": True,
},
"system_admin": {
"name": "System administrator",
"description": "Manage tenants, accounts, system roles, settings and governance without being able to grant the protected System owner role.",
"permissions": sorted(SYSTEM_SCOPES),
"is_builtin": False,
"is_assignable": True,
"managed": False,
},
"system_auditor": {
"name": "System auditor",
"description": "Read tenant registry, accounts, system roles, settings, governance and cross-tenant audit records.",
"permissions": [
"system:tenants:read", "system:accounts:read", "system:roles:read", "system:access:read",
"system:audit:read", "system:settings:read", "system:governance:read",
],
"is_builtin": False,
"is_assignable": True,
"managed": False,
},
}
def normalize_email(value: str) -> str:
return value.strip().casefold()
def scope_grants(granted: str, required: str) -> bool:
if granted == required:
return True
if required in LEGACY_SCOPE_ALIASES.get(granted, frozenset()):
return True
if granted in {"*", "tenant:*"}:
return required in {"*", "tenant:*"} or (required in TENANT_SCOPES) or required in {"attachments:read", "attachments:write"}
if granted == "system:*":
return required.startswith("system:")
if granted.endswith(":*") and required.startswith(granted[:-1]):
return True
return any(scope_grants(alias, required) for alias in LEGACY_SCOPE_ALIASES.get(granted, frozenset()) if alias != granted)
def scopes_grant(scopes: Iterable[str], required: str) -> bool:
return any(scope_grants(scope, required) for scope in scopes)
def effective_permission_scopes(scopes: Iterable[str], *, level: str | None = None) -> set[str]:
"""Return canonical permissions granted by a possibly wildcarded scope set.
Wildcards and compatibility aliases are presentation/assignment shorthand;
counts and access summaries should describe the canonical capabilities they
grant rather than counting the shorthand token itself.
"""
if level == "tenant":
candidates = TENANT_SCOPES
elif level == "system":
candidates = SYSTEM_SCOPES
else:
candidates = KNOWN_SCOPES
granted = list(scopes)
return {scope for scope in candidates if scopes_grant(granted, scope)}
def effective_permission_count(scopes: Iterable[str], *, level: str | None = None) -> int:
return len(effective_permission_scopes(scopes, level=level))
def expand_scopes(scopes: Iterable[str], *, include_unknown: bool = True) -> list[str]:
raw = {str(scope) for scope in scopes if scope}
expanded: set[str] = set()
for scope in raw:
if scope in {"*", "tenant:*"}:
expanded.add("tenant:*")
expanded.update(TENANT_SCOPES)
continue
if scope == "system:*":
expanded.add("system:*")
expanded.update(SYSTEM_SCOPES)
continue
aliases = LEGACY_SCOPE_ALIASES.get(scope, frozenset())
expanded.update(aliases)
for alias in aliases:
if alias.endswith(":*"):
expanded.update(item for item in KNOWN_SCOPES if scope_grants(alias, item))
if scope.endswith(":*"):
expanded.update(item for item in KNOWN_SCOPES if scope_grants(scope, item))
expanded.add(scope)
elif include_unknown or scope in KNOWN_SCOPES:
expanded.add(scope)
return sorted(expanded)
def delegateable_tenant_scopes(scopes: Iterable[str]) -> set[str]:
expanded = set(expand_scopes(scopes, include_unknown=False))
if "tenant:*" in expanded:
return set(TENANT_SCOPES)
return {scope for scope in expanded if scope in TENANT_SCOPES}
def delegateable_system_scopes(scopes: Iterable[str]) -> set[str]:
expanded = set(expand_scopes(scopes, include_unknown=False))
if "system:*" in expanded:
return set(SYSTEM_SCOPES)
return {scope for scope in expanded if scope in SYSTEM_SCOPES}
def intersect_api_key_scopes(user_scopes: Iterable[str], key_scopes: Iterable[str]) -> list[str]:
user = list(user_scopes)
key = list(key_scopes)
allowed = {scope for scope in TENANT_SCOPES if scopes_grant(user, scope) and scopes_grant(key, scope)}
user_raw = set(expand_scopes(user))
key_raw = set(expand_scopes(key))
allowed.update(scope for scope in user_raw.intersection(key_raw) if not scope.startswith("system:") and scope not in {"*", "tenant:*"})
return sorted(allowed)
def validate_tenant_permissions(scopes: Iterable[str]) -> list[str]:
normalized = {str(scope) for scope in scopes if scope}
if normalized.intersection({"*", "tenant:*"}):
return ["tenant:*"]
expanded: set[str] = set()
invalid: list[str] = []
for scope in sorted(normalized):
if scope in TENANT_SCOPES:
expanded.add(scope)
continue
aliases = {item for item in LEGACY_SCOPE_ALIASES.get(scope, frozenset()) if item in TENANT_SCOPES}
if aliases:
expanded.update(aliases)
continue
invalid.append(scope)
if invalid:
raise ValueError(f"Unsupported tenant permissions: {', '.join(invalid)}")
return sorted(expanded)
def validate_system_permissions(scopes: Iterable[str]) -> list[str]:
normalized = sorted({str(scope) for scope in scopes if scope})
invalid = [scope for scope in normalized if scope not in SYSTEM_SCOPES and scope != "system:*"]
if invalid:
raise ValueError(f"Unsupported system permissions: {', '.join(invalid)}")
if "system:*" in normalized:
return ["system:*"]
return normalized
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
import base64
import hashlib
from functools import lru_cache
from cryptography.fernet import Fernet, InvalidToken
from govoplan_core.settings import settings
class SecretConfigurationError(RuntimeError):
pass
class SecretDecryptionError(RuntimeError):
pass
def _normalize_fernet_key(value: str) -> bytes:
candidate = value.strip().encode("utf-8")
try:
Fernet(candidate)
return candidate
except Exception:
pass
try:
raw = base64.b64decode(candidate)
except Exception as exc:
raise SecretConfigurationError("MASTER_KEY_B64 must be a Fernet key or base64-encoded 32-byte key") from exc
if len(raw) != 32:
raise SecretConfigurationError("MASTER_KEY_B64 must decode to exactly 32 bytes")
return base64.urlsafe_b64encode(raw)
@lru_cache(maxsize=1)
def _fernet() -> Fernet:
if settings.master_key_b64:
return Fernet(_normalize_fernet_key(settings.master_key_b64))
if settings.app_env.lower() in {"dev", "test", "local"}:
key = base64.urlsafe_b64encode(hashlib.sha256(b"multi-seal-mail-development-master-key").digest())
return Fernet(key)
raise SecretConfigurationError("MASTER_KEY_B64 is required outside dev/test/local environments")
def encrypt_secret(value: str | None) -> str | None:
if value in (None, ""):
return None
return _fernet().encrypt(str(value).encode("utf-8")).decode("utf-8")
def decrypt_secret(value: str | None) -> str | None:
if not value:
return None
try:
return _fernet().decrypt(value.encode("utf-8")).decode("utf-8")
except InvalidToken as exc:
raise SecretDecryptionError("Stored secret cannot be decrypted with the configured master key") from exc
+220
View File
@@ -0,0 +1,220 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from sqlalchemy.orm import Session
from govoplan_core.db.models import (
Account,
AuthSession,
Group,
GroupRoleAssignment,
Role,
SystemRoleAssignment,
Tenant,
User,
UserGroupMembership,
UserRoleAssignment,
)
from govoplan_core.access.auth.tokens import generate_secret, hash_secret, verify_secret
from govoplan_core.security.permissions import expand_scopes
from govoplan_core.security.time import ensure_aware_utc, utc_now
SESSION_RANDOM_BYTES = 32
DEFAULT_SESSION_HOURS = 12
@dataclass(slots=True)
class CreatedSession:
model: AuthSession
token: str
csrf_token: str
def generate_session_token() -> str:
return generate_secret("ms_", random_bytes=SESSION_RANDOM_BYTES)
def hash_session_token(token: str) -> str:
return hash_secret(token)
def generate_csrf_token() -> str:
return generate_secret("", random_bytes=SESSION_RANDOM_BYTES)
def hash_csrf_token(token: str) -> str:
return hash_secret(token)
def verify_session_token(token: str, expected_hash: str) -> bool:
return verify_secret(token, expected_hash)
def verify_auth_session_csrf(auth_session: AuthSession, csrf_token: str | None) -> bool:
if not csrf_token or not auth_session.csrf_token_hash:
return False
return verify_secret(csrf_token, auth_session.csrf_token_hash)
def create_auth_session(
session: Session,
*,
user: User,
hours: int = DEFAULT_SESSION_HOURS,
user_agent: str | None = None,
ip_address: str | None = None,
) -> CreatedSession:
if not user.account_id:
raise ValueError("Tenant membership has no global account.")
token = generate_session_token()
csrf_token = generate_csrf_token()
now = utc_now()
model = AuthSession(
tenant_id=user.tenant_id,
user_id=user.id,
account_id=user.account_id,
token_hash=hash_session_token(token),
csrf_token_hash=hash_csrf_token(csrf_token),
expires_at=now + timedelta(hours=hours),
last_seen_at=now,
user_agent=user_agent,
ip_address=ip_address,
)
user.last_login_at = now
if user.account:
user.account.last_login_at = now
session.add(user.account)
session.add(model)
session.add(user)
session.flush()
return CreatedSession(model=model, token=token, csrf_token=csrf_token)
def authenticate_session_token(session: Session, token: str) -> AuthSession | None:
token_hash = hash_session_token(token)
model = session.query(AuthSession).filter(AuthSession.token_hash == token_hash, AuthSession.revoked_at.is_(None)).one_or_none()
if not model:
return None
now = utc_now()
expires_at = ensure_aware_utc(model.expires_at)
if expires_at is None or expires_at < now:
return None
model.last_seen_at = now
session.add(model)
return model
def revoke_auth_session(session: Session, token: str) -> bool:
model = authenticate_session_token(session, token)
if not model:
return False
model.revoked_at = utc_now()
session.add(model)
return True
def switch_auth_session_tenant(session: Session, auth_session: AuthSession, tenant_id: str) -> User:
membership = (
session.query(User)
.join(Tenant, Tenant.id == User.tenant_id)
.filter(
User.account_id == auth_session.account_id,
User.tenant_id == tenant_id,
User.is_active.is_(True),
Tenant.is_active.is_(True),
)
.one_or_none()
)
if membership is None:
raise LookupError("The account does not have an active membership in this tenant.")
auth_session.tenant_id = membership.tenant_id
auth_session.user_id = membership.id
auth_session.last_seen_at = utc_now()
session.add(auth_session)
session.flush()
return membership
def collect_direct_user_roles(session: Session, user: User) -> list[Role]:
return (
session.query(Role)
.join(UserRoleAssignment, UserRoleAssignment.role_id == Role.id)
.filter(
UserRoleAssignment.user_id == user.id,
UserRoleAssignment.tenant_id == user.tenant_id,
Role.tenant_id == user.tenant_id,
)
.order_by(Role.name.asc())
.all()
)
def collect_user_roles(session: Session, user: User) -> list[Role]:
roles_by_id: dict[str, Role] = {role.id: role for role in collect_direct_user_roles(session, user)}
group_roles = (
session.query(Role)
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
.join(UserGroupMembership, UserGroupMembership.group_id == GroupRoleAssignment.group_id)
.join(Group, Group.id == UserGroupMembership.group_id)
.filter(
UserGroupMembership.user_id == user.id,
UserGroupMembership.tenant_id == user.tenant_id,
Group.is_active.is_(True),
Role.tenant_id == user.tenant_id,
)
.all()
)
for role in group_roles:
roles_by_id[role.id] = role
return list(roles_by_id.values())
def collect_system_roles(session: Session, account: Account) -> list[Role]:
return (
session.query(Role)
.join(SystemRoleAssignment, SystemRoleAssignment.role_id == Role.id)
.filter(SystemRoleAssignment.account_id == account.id, Role.tenant_id.is_(None))
.order_by(Role.name.asc())
.all()
)
def collect_user_groups(session: Session, user: User) -> list[Group]:
return (
session.query(Group)
.join(UserGroupMembership, UserGroupMembership.group_id == Group.id)
.filter(
UserGroupMembership.user_id == user.id,
UserGroupMembership.tenant_id == user.tenant_id,
Group.is_active.is_(True),
)
.order_by(Group.name.asc())
.all()
)
def collect_user_scopes(session: Session, user: User, *, include_system: bool = True) -> list[str]:
scopes: set[str] = set()
for role in collect_user_roles(session, user):
scopes.update(role.permissions or [])
if include_system and user.account:
for role in collect_system_roles(session, user.account):
scopes.update(role.permissions or [])
return expand_scopes(scopes)
def collect_tenant_memberships(session: Session, account: Account) -> list[tuple[User, Tenant]]:
return (
session.query(User, Tenant)
.join(Tenant, Tenant.id == User.tenant_id)
.filter(
User.account_id == account.id,
User.is_active.is_(True),
Tenant.is_active.is_(True),
)
.order_by(Tenant.name.asc())
.all()
)
+21
View File
@@ -0,0 +1,21 @@
from __future__ import annotations
from datetime import datetime, timezone
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def ensure_aware_utc(value: datetime | None) -> datetime | None:
"""Return a timezone-aware UTC datetime.
SQLite and some DB drivers may return naive datetimes even when SQLAlchemy
columns are declared as DateTime(timezone=True). Treat naive values as UTC
so comparisons with aware `datetime.now(timezone.utc)` do not crash.
"""
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)