Release v0.1.6
This commit is contained in:
@@ -1,80 +0,0 @@
|
||||
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
|
||||
@@ -1,37 +0,0 @@
|
||||
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)
|
||||
@@ -86,6 +86,7 @@ SYSTEM_PERMISSIONS: tuple[PermissionDefinition, ...] = (
|
||||
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:maintenance:access", "Access during maintenance", "Use the system while maintenance mode is active.", "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"),
|
||||
)
|
||||
@@ -174,6 +175,14 @@ DEFAULT_SYSTEM_ROLES: dict[str, dict[str, object]] = {
|
||||
"is_assignable": True,
|
||||
"managed": False,
|
||||
},
|
||||
"maintenance_operator": {
|
||||
"name": "Maintenance operator",
|
||||
"description": "Access the system while maintenance mode is active.",
|
||||
"permissions": ["system:settings:read", "system:maintenance:access"],
|
||||
"is_builtin": False,
|
||||
"is_assignable": True,
|
||||
"managed": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -186,8 +195,10 @@ def scope_grants(granted: str, required: str) -> bool:
|
||||
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 == "*":
|
||||
return True
|
||||
if granted == "tenant:*":
|
||||
return required == "tenant:*" or not required.startswith("system:")
|
||||
if granted == "system:*":
|
||||
return required.startswith("system:")
|
||||
if granted.endswith(":*") and required.startswith(granted[:-1]):
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
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()
|
||||
)
|
||||
Reference in New Issue
Block a user