initial commit after split
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user