commit f37c6668e5f34f4b17118c6be63f9656c4891b6e Author: Albrecht Degering Date: Mon Jul 6 11:38:26 2026 +0200 Create access module package diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.gitea/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..f94e5f5 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +name: "Bug" +about: "Report a reproducible defect, regression, or incorrect behavior" +title: "[Bug] " +labels: + - type/bug + - status/triage + - module/access +--- + +## Scope + +- Repository: +- Area/module: +- Affected version or commit: + +## Behavior + +Expected: + +Actual: + +## Reproduction + +1. +2. +3. + +## Evidence + +Logs, screenshots, traces, or failing test output: + +## Verification Target + +Command or workflow that should pass when fixed: diff --git a/.gitea/ISSUE_TEMPLATE/config.yaml b/.gitea/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.gitea/ISSUE_TEMPLATE/docs_workflow.md b/.gitea/ISSUE_TEMPLATE/docs_workflow.md new file mode 100644 index 0000000..8c55c52 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/docs_workflow.md @@ -0,0 +1,27 @@ +--- +name: "Docs / workflow" +about: "Request documentation, process, or developer workflow changes" +title: "[Docs] " +labels: + - type/docs + - status/triage + - module/access + - area/docs +--- + +## Scope + +- Repository: +- Document or workflow: + +## Current State + +What is missing, unclear, duplicated, or stale? + +## Desired State + +What should the docs or workflow make clear? + +## Verification Target + +How should this be checked? diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.md b/.gitea/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..df829a9 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,32 @@ +--- +name: "Feature" +about: "Propose new user-visible behavior or platform capability" +title: "[Feature] " +labels: + - type/feature + - status/triage + - module/access +--- + +## Problem + +What user, operator, or developer problem should this solve? + +## Proposed Capability + +What should exist when this is done? + +## Ownership + +- Owning repository: +- Related module repositories: +- Extension point or integration boundary: + +## Acceptance Criteria + +- [ ] +- [ ] + +## Verification Target + +Command, scenario, or UI flow that should prove completion: diff --git a/.gitea/ISSUE_TEMPLATE/task.md b/.gitea/ISSUE_TEMPLATE/task.md new file mode 100644 index 0000000..9617db8 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/task.md @@ -0,0 +1,28 @@ +--- +name: "Task" +about: "Track implementation, maintenance, or migration work" +title: "[Task] " +labels: + - type/task + - status/triage + - module/access +--- + +## Objective + +What needs to be completed? + +## Scope + +- Owning repository: +- In-scope: +- Out-of-scope: + +## Checklist + +- [ ] +- [ ] + +## Verification Target + +Command or manual check: diff --git a/.gitea/ISSUE_TEMPLATE/tech_debt.md b/.gitea/ISSUE_TEMPLATE/tech_debt.md new file mode 100644 index 0000000..651a6c4 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/tech_debt.md @@ -0,0 +1,25 @@ +--- +name: "Tech debt" +about: "Track cleanup, refactoring, risk reduction, or deferred engineering work" +title: "[Debt] " +labels: + - type/debt + - status/triage + - module/access +--- + +## Current Cost + +What does this make harder, riskier, slower, or more fragile? + +## Desired Shape + +What should the code, tests, or architecture look like afterwards? + +## Constraints + +What behavior, compatibility, or module boundary must be preserved? + +## Verification Target + +Focused checks that should pass: diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1984736 --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +## Issue + +Closes # + +## Summary + +- + +## Verification + +- + +## Notes + +Follow-up issues: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d4d907f --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +.eggs/ +.env +.venv/ +node_modules/ +*.tsbuildinfo diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1a5d251 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,28 @@ +# GovOPlaN Access Codex Guide + +## Scope + +This repository owns the GovOPlaN access platform module seed: identity, +authentication, sessions, API keys, users, groups, roles, memberships, access +administration, and access-owned migrations. + +During the transition, some live routes and legacy ORM models still sit in +`/mnt/DATA/git/govoplan-core` as compatibility code. Keep moving ownership here +behind core compatibility imports and kernel capability contracts. + +## Boundaries + +- Depend on kernel contracts from `govoplan-core`. +- Do not import feature-module internals from mail, files, or campaign. +- Expose integration through manifests, capabilities, API routes, events, and + typed DTOs. +- Keep WebUI access administration as module route contributions once the + frontend move begins. + +## Local Workflow + +Use Gitea issues as the canonical backlog and state log. The shared workflow is +installed under `.gitea/`, with labels in `docs/gitea-labels.json`. + +Focused verification is still driven from `/mnt/DATA/git/govoplan-core` for +cross-repository behavior. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2d41fec --- /dev/null +++ b/LICENSE @@ -0,0 +1,3 @@ +Proprietary. + +Copyright GovOPlaN contributors. All rights reserved. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b1c178 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# GovOPlaN Access + +`govoplan-access` is the platform module for GovOPlaN identity, +authentication, sessions, API keys, RBAC, groups, users, and access +administration. + +The repository contains the extracted access seed implementation under +`src/govoplan_access/backend`. Some live product routes and legacy ORM models +still reside in `govoplan-core` as compatibility code while the staged +extraction continues. The staged extraction path is documented in: + +- `/mnt/DATA/git/govoplan-core/docs/ACCESS_EXTRACTION_PLAN.md` +- `/mnt/DATA/git/govoplan-core/docs/MODULE_ARCHITECTURE.md` + +## Initial Ownership + +This module will own: + +- accounts and login identity +- authentication routes and session lifecycle +- API keys +- tenant-local users +- groups and memberships +- roles and role assignments +- principal resolution +- permission evaluation +- access administration backend routes +- access administration WebUI route contributions +- access-owned migrations + +The kernel remains responsible for module discovery, route aggregation, +database/session lifecycle, migration orchestration, capability registry, and +stable contracts. + +## Gitea Workflow + +This repository uses the shared GovOPlaN Gitea issue workflow. Issue templates +are installed under `.gitea/`, and the shared label taxonomy is copied to +`docs/gitea-labels.json`. + +From the core checkout, labels can be synced once a local `GITEA_TOKEN` is +available: + +```bash +cd /mnt/DATA/git/govoplan-core +./scripts/gitea-sync-labels.py --root /mnt/DATA/git/govoplan-access --apply +``` + +## Development Install + +From the core checkout: + +```bash +cd /mnt/DATA/git/govoplan-core +./.venv/bin/python -m pip install -e ../govoplan-access +``` diff --git a/docs/gitea-labels.json b/docs/gitea-labels.json new file mode 100644 index 0000000..508dfd0 --- /dev/null +++ b/docs/gitea-labels.json @@ -0,0 +1,206 @@ +[ + { + "name": "type/bug", + "color": "d73a4a", + "description": "A reproducible defect, regression, or incorrect behavior.", + "exclusive": true + }, + { + "name": "type/feature", + "color": "0e8a16", + "description": "New user-visible behavior or platform capability.", + "exclusive": true + }, + { + "name": "type/task", + "color": "1d76db", + "description": "Implementation, maintenance, migration, or operational work.", + "exclusive": true + }, + { + "name": "type/debt", + "color": "fbca04", + "description": "Cleanup, refactoring, risk reduction, or deferred engineering work.", + "exclusive": true + }, + { + "name": "type/docs", + "color": "5319e7", + "description": "Documentation, process, or developer workflow work.", + "exclusive": true + }, + { + "name": "priority/p0", + "color": "b60205", + "description": "Immediate stop-the-line priority.", + "exclusive": true + }, + { + "name": "priority/p1", + "color": "d93f0b", + "description": "High priority for the next focused work window.", + "exclusive": true + }, + { + "name": "priority/p2", + "color": "fbca04", + "description": "Normal planned priority.", + "exclusive": true + }, + { + "name": "priority/p3", + "color": "c2e0c6", + "description": "Low priority or opportunistic cleanup.", + "exclusive": true + }, + { + "name": "status/triage", + "color": "d4c5f9", + "description": "Needs review, ownership, priority, or acceptance criteria.", + "exclusive": true + }, + { + "name": "status/ready", + "color": "0e8a16", + "description": "Ready for implementation.", + "exclusive": true + }, + { + "name": "status/in-progress", + "color": "0052cc", + "description": "Currently being worked.", + "exclusive": true + }, + { + "name": "status/blocked", + "color": "b60205", + "description": "Cannot progress without a decision, dependency, credential, or external change.", + "exclusive": true + }, + { + "name": "status/needs-info", + "color": "f9d0c4", + "description": "Needs clarifying input before implementation can proceed safely.", + "exclusive": true + }, + { + "name": "module/core", + "color": "0052cc", + "description": "GovOPlaN core runner, shared primitives, shell, or extension points.", + "exclusive": false + }, + { + "name": "module/mail", + "color": "5319e7", + "description": "GovOPlaN mail module behavior or integration.", + "exclusive": false + }, + { + "name": "module/files", + "color": "006b75", + "description": "GovOPlaN files module behavior or integration.", + "exclusive": false + }, + { + "name": "module/campaign", + "color": "d876e3", + "description": "GovOPlaN campaign module behavior or integration.", + "exclusive": false + }, + { + "name": "module/access", + "color": "0e8a16", + "description": "GovOPlaN access, identity, authentication, RBAC, and administration behavior.", + "exclusive": false + }, + { + "name": "area/auth", + "color": "bfd4f2", + "description": "Authentication, sessions, access bootstrap, or login behavior.", + "exclusive": false + }, + { + "name": "area/tenancy", + "color": "bfdadc", + "description": "Tenant boundaries, provisioning, or tenant-scoped data behavior.", + "exclusive": false + }, + { + "name": "area/rbac", + "color": "c5def5", + "description": "Permissions, roles, delegation, or authorization policy.", + "exclusive": false + }, + { + "name": "area/governance", + "color": "fef2c0", + "description": "Governance policy, audit, privacy, retention, or compliance behavior.", + "exclusive": false + }, + { + "name": "area/module-system", + "color": "bfe5bf", + "description": "Module discovery, manifests, capabilities, routing, or optional integrations.", + "exclusive": false + }, + { + "name": "area/migrations", + "color": "e4e669", + "description": "Alembic migrations, schema bootstrap, or persistence evolution.", + "exclusive": false + }, + { + "name": "area/webui", + "color": "1d76db", + "description": "Shared WebUI shell, frontend components, routing, or frontend tests.", + "exclusive": false + }, + { + "name": "area/api", + "color": "5319e7", + "description": "HTTP API contracts, routers, schemas, or API smoke behavior.", + "exclusive": false + }, + { + "name": "area/db", + "color": "0e8a16", + "description": "Database sessions, models, transactions, or persistence primitives.", + "exclusive": false + }, + { + "name": "area/devex", + "color": "bfd4f2", + "description": "Local developer workflow, scripts, tests, tooling, or release helpers.", + "exclusive": false + }, + { + "name": "area/release", + "color": "c2e0c6", + "description": "Versioning, release locks, tags, packaging, or dependency pins.", + "exclusive": false + }, + { + "name": "area/docs", + "color": "5319e7", + "description": "Durable documentation and project guidance.", + "exclusive": false + }, + { + "name": "source/todo-scan", + "color": "cccccc", + "description": "Imported from inline TODO/FIXME/HACK markers by the Gitea TODO importer.", + "exclusive": false + }, + { + "name": "codex/ready", + "color": "0e8a16", + "description": "Suitable for Codex to pick up with the existing issue context.", + "exclusive": false + }, + { + "name": "codex/needs-human", + "color": "f9d0c4", + "description": "Needs an explicit human decision before Codex should implement.", + "exclusive": false + } +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..da85327 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "govoplan-access" +version = "0.1.4" +description = "GovOPlaN access platform module with identity, auth, RBAC, and tenancy primitives." +readme = "README.md" +requires-python = ">=3.12" +license = { file = "LICENSE" } +authors = [{ name = "GovOPlaN" }] +dependencies = [ + "govoplan-core>=0.1.4", + "SQLAlchemy>=2,<3", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +govoplan_access = ["py.typed"] + +[project.entry-points."govoplan.modules"] +access = "govoplan_access.backend.manifest:get_manifest" diff --git a/src/govoplan_access/__init__.py b/src/govoplan_access/__init__.py new file mode 100644 index 0000000..99ca0d5 --- /dev/null +++ b/src/govoplan_access/__init__.py @@ -0,0 +1,3 @@ +"""GovOPlaN access platform module.""" + +__version__ = "0.1.4" diff --git a/src/govoplan_access/backend/__init__.py b/src/govoplan_access/backend/__init__.py new file mode 100644 index 0000000..6317117 --- /dev/null +++ b/src/govoplan_access/backend/__init__.py @@ -0,0 +1 @@ +"""Backend integration for the GovOPlaN access module.""" diff --git a/src/govoplan_access/backend/auth/__init__.py b/src/govoplan_access/backend/auth/__init__.py new file mode 100644 index 0000000..3e58a06 --- /dev/null +++ b/src/govoplan_access/backend/auth/__init__.py @@ -0,0 +1,2 @@ +"""Authentication and principal helpers for platform access.""" + diff --git a/src/govoplan_access/backend/auth/principals.py b/src/govoplan_access/backend/auth/principals.py new file mode 100644 index 0000000..57d128f --- /dev/null +++ b/src/govoplan_access/backend/auth/principals.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from govoplan_access.backend.permissions.evaluator import scopes_grant + + +AuthMethod = Literal["session", "api_key", "service_account"] + + +@dataclass(frozen=True, slots=True) +class Principal: + account_id: str + membership_id: str | None + tenant_id: str | None + scopes: frozenset[str] + group_ids: frozenset[str] + auth_method: AuthMethod + api_key_id: str | None = None + session_id: str | None = None + service_account_id: str | None = None + email: str | None = None + display_name: str | None = None + + def has(self, required: str) -> bool: + return scopes_grant(self.scopes, required) diff --git a/src/govoplan_access/backend/auth/roles.py b/src/govoplan_access/backend/auth/roles.py new file mode 100644 index 0000000..20cc8d8 --- /dev/null +++ b/src/govoplan_access/backend/auth/roles.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from sqlalchemy.orm import Session + +from govoplan_access.backend.db.models import Account, Group, GroupMembership, Membership, Role, RoleBinding +from govoplan_access.backend.permissions.evaluator import expand_scopes +from govoplan_core.core.modules import PermissionDefinition, SubjectType + + +def membership_group_ids(session: Session, *, tenant_id: str, membership_id: str) -> frozenset[str]: + rows = ( + session.query(Group.id) + .join(GroupMembership, GroupMembership.group_id == Group.id) + .filter( + GroupMembership.tenant_id == tenant_id, + GroupMembership.membership_id == membership_id, + Group.is_active.is_(True), + ) + .all() + ) + return frozenset(row[0] for row in rows) + + +def roles_for_subject( + session: Session, + *, + subject_type: SubjectType, + subject_id: str, + tenant_id: str | None, +) -> list[Role]: + query = ( + session.query(Role) + .join(RoleBinding, RoleBinding.role_id == Role.id) + .filter( + RoleBinding.subject_type == subject_type, + RoleBinding.subject_id == subject_id, + ) + ) + if tenant_id is None: + query = query.filter(RoleBinding.tenant_id.is_(None), Role.tenant_id.is_(None)) + else: + query = query.filter(RoleBinding.tenant_id == tenant_id, Role.tenant_id == tenant_id) + return query.order_by(Role.name.asc()).all() + + +def membership_roles(session: Session, membership: Membership) -> list[Role]: + roles_by_id = { + role.id: role + for role in roles_for_subject( + session, + subject_type="membership", + subject_id=membership.id, + tenant_id=membership.tenant_id, + ) + } + for group_id in membership_group_ids(session, tenant_id=membership.tenant_id, membership_id=membership.id): + for role in roles_for_subject(session, subject_type="group", subject_id=group_id, tenant_id=membership.tenant_id): + roles_by_id[role.id] = role + return list(roles_by_id.values()) + + +def account_system_roles(session: Session, account: Account) -> list[Role]: + return roles_for_subject(session, subject_type="account", subject_id=account.id, tenant_id=None) + + +def principal_scopes( + session: Session, + *, + account: Account, + membership: Membership | None, + include_system: bool, + catalog: dict[str, PermissionDefinition], +) -> list[str]: + scopes: set[str] = set() + if membership is not None: + for role in membership_roles(session, membership): + scopes.update(role.permissions or []) + if include_system: + for role in account_system_roles(session, account): + scopes.update(role.permissions or []) + return expand_scopes(scopes, catalog=catalog) diff --git a/src/govoplan_access/backend/auth/tokens.py b/src/govoplan_access/backend/auth/tokens.py new file mode 100644 index 0000000..a1bf8e1 --- /dev/null +++ b/src/govoplan_access/backend/auth/tokens.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import hashlib +import hmac +import secrets + + +DEFAULT_RANDOM_BYTES = 32 + + +def generate_secret(prefix: str, *, random_bytes: int = DEFAULT_RANDOM_BYTES) -> str: + return prefix + secrets.token_urlsafe(random_bytes) + + +def hash_secret(secret: str) -> str: + return hashlib.sha256(secret.encode("utf-8")).hexdigest() + + +def verify_secret(secret: str, expected_hash: str) -> bool: + return hmac.compare_digest(hash_secret(secret), expected_hash) + diff --git a/src/govoplan_access/backend/db/__init__.py b/src/govoplan_access/backend/db/__init__.py new file mode 100644 index 0000000..81aa90c --- /dev/null +++ b/src/govoplan_access/backend/db/__init__.py @@ -0,0 +1,2 @@ +"""Access-platform SQLAlchemy metadata and models.""" + diff --git a/src/govoplan_access/backend/db/base.py b/src/govoplan_access/backend/db/base.py new file mode 100644 index 0000000..2bfbd5f --- /dev/null +++ b/src/govoplan_access/backend/db/base.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from typing import Any + +from sqlalchemy import JSON, MetaData +from sqlalchemy.orm import DeclarativeBase + +from govoplan_core.db.base import NAMING_CONVENTION, TimestampMixin, utcnow + + +class AccessBase(DeclarativeBase): + metadata = MetaData(naming_convention=NAMING_CONVENTION) + + type_annotation_map = { + dict[str, Any]: JSON, + list[dict[str, Any]]: JSON, + list[str]: JSON, + } + + +__all__ = ["AccessBase", "NAMING_CONVENTION", "TimestampMixin", "utcnow"] diff --git a/src/govoplan_access/backend/db/models.py b/src/govoplan_access/backend/db/models.py new file mode 100644 index 0000000..31ede2d --- /dev/null +++ b/src/govoplan_access/backend/db/models.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any + +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint, text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from govoplan_access.backend.db.base import AccessBase, TimestampMixin + + +def new_uuid() -> str: + return str(uuid.uuid4()) + + +class Account(AccessBase, TimestampMixin): + __tablename__ = "access_accounts" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + email: Mapped[str] = mapped_column(String(320), nullable=False) + normalized_email: Mapped[str] = mapped_column(String(320), nullable=False, unique=True, index=True) + display_name: Mapped[str | None] = mapped_column(String(255)) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + auth_provider: Mapped[str] = mapped_column(String(50), default="local", nullable=False) + password_hash: Mapped[str | None] = mapped_column(String(500)) + password_reset_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + memberships: Mapped[list[Membership]] = relationship(back_populates="account", cascade="all, delete-orphan") + + +class Tenant(AccessBase, TimestampMixin): + __tablename__ = "access_tenants" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + slug: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + default_locale: Mapped[str] = mapped_column(String(20), default="en", nullable=False) + settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + memberships: Mapped[list[Membership]] = relationship(back_populates="tenant", cascade="all, delete-orphan") + + +class Membership(AccessBase, TimestampMixin): + __tablename__ = "access_memberships" + __table_args__ = (UniqueConstraint("tenant_id", "account_id", name="uq_access_memberships_tenant_account"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=False, index=True) + account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True) + email_snapshot: Mapped[str | None] = mapped_column(String(320), index=True) + display_name: Mapped[str | None] = mapped_column(String(255)) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + account: Mapped[Account] = relationship(back_populates="memberships") + tenant: Mapped[Tenant] = relationship(back_populates="memberships") + + +class Group(AccessBase, TimestampMixin): + __tablename__ = "access_groups" + __table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_access_groups_tenant_slug"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=False, index=True) + slug: Mapped[str] = mapped_column(String(100), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + template_id: Mapped[str | None] = mapped_column(String(100), index=True) + is_protected: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + +class GroupMembership(AccessBase, TimestampMixin): + __tablename__ = "access_group_memberships" + __table_args__ = ( + UniqueConstraint("tenant_id", "membership_id", "group_id", name="uq_access_group_memberships_member_group"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=False, index=True) + membership_id: Mapped[str] = mapped_column(ForeignKey("access_memberships.id", ondelete="CASCADE"), nullable=False, index=True) + group_id: Mapped[str] = mapped_column(ForeignKey("access_groups.id", ondelete="CASCADE"), nullable=False, index=True) + + +class Role(AccessBase, TimestampMixin): + __tablename__ = "access_roles" + __table_args__ = ( + UniqueConstraint("tenant_id", "slug", name="uq_access_roles_tenant_slug"), + Index( + "uq_access_roles_system_slug", + "slug", + unique=True, + sqlite_where=text("tenant_id IS NULL"), + postgresql_where=text("tenant_id IS NULL"), + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str | None] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=True, index=True) + slug: Mapped[str] = mapped_column(String(100), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + permissions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) + level: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False, index=True) + is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + is_assignable: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + template_id: Mapped[str | None] = mapped_column(String(100), index=True) + is_protected: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + +class RoleBinding(AccessBase, TimestampMixin): + __tablename__ = "access_role_bindings" + __table_args__ = ( + Index("ix_access_role_bindings_subject", "subject_type", "subject_id"), + Index("ix_access_role_bindings_tenant_subject", "tenant_id", "subject_type", "subject_id"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str | None] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=True, index=True) + role_id: Mapped[str] = mapped_column(ForeignKey("access_roles.id", ondelete="CASCADE"), nullable=False, index=True) + subject_type: Mapped[str] = mapped_column(String(50), nullable=False) + subject_id: Mapped[str] = mapped_column(String(36), nullable=False) + created_by_account_id: Mapped[str | None] = mapped_column(String(36), index=True) + created_by_membership_id: Mapped[str | None] = mapped_column(String(36), index=True) + + +class PermissionCatalogEntry(AccessBase, TimestampMixin): + __tablename__ = "access_permission_catalog" + + scope: Mapped[str] = mapped_column(String(255), primary_key=True) + module_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + resource: Mapped[str] = mapped_column(String(100), nullable=False) + action: Mapped[str] = mapped_column(String(100), nullable=False) + label: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str] = mapped_column(Text, default="", nullable=False) + category: Mapped[str] = mapped_column(String(100), nullable=False) + level: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + is_deprecated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + +class RoleTemplateModel(AccessBase, TimestampMixin): + __tablename__ = "access_role_templates" + __table_args__ = (UniqueConstraint("module_id", "level", "slug", name="uq_access_role_templates_module_level_slug"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + module_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + slug: Mapped[str] = mapped_column(String(100), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + permissions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) + level: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False) + managed: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + protected: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + +class ModuleInstallation(AccessBase, TimestampMixin): + __tablename__ = "access_module_installations" + + module_id: Mapped[str] = mapped_column(String(100), primary_key=True) + version: Mapped[str] = mapped_column(String(50), nullable=False) + enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + +class AuthSession(AccessBase, TimestampMixin): + __tablename__ = "access_auth_sessions" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str | None] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=True, index=True) + membership_id: Mapped[str | None] = mapped_column(ForeignKey("access_memberships.id", ondelete="CASCADE"), nullable=True, index=True) + account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True) + token_hash: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True) + csrf_token_hash: Mapped[str | None] = mapped_column(String(255)) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) + user_agent: Mapped[str | None] = mapped_column(String(500)) + ip_address: Mapped[str | None] = mapped_column(String(100)) + + +class ApiKey(AccessBase, TimestampMixin): + __tablename__ = "access_api_keys" + __table_args__ = (Index("ix_access_api_keys_owner", "owner_subject_type", "owner_subject_id"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=False, index=True) + owner_subject_type: Mapped[str] = mapped_column(String(50), nullable=False) + owner_subject_id: Mapped[str] = mapped_column(String(36), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + prefix: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + key_hash: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + scopes: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) + + +class TenantDatastore(AccessBase, TimestampMixin): + __tablename__ = "access_tenant_datastores" + __table_args__ = (UniqueConstraint("tenant_id", "module_id", name="uq_access_tenant_datastores_tenant_module"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), nullable=False, index=True) + module_id: Mapped[str] = mapped_column(String(100), default="*", nullable=False) + mode: Mapped[str] = mapped_column(String(20), default="shared", nullable=False) + database_url_secret_ref: Mapped[str | None] = mapped_column(String(255)) + schema_name: Mapped[str | None] = mapped_column(String(100)) + storage_bucket: Mapped[str | None] = mapped_column(String(255)) + region: Mapped[str | None] = mapped_column(String(100)) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + +class SystemSettings(AccessBase, TimestampMixin): + __tablename__ = "access_system_settings" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default="global") + default_locale: Mapped[str] = mapped_column(String(20), default="en", nullable=False) + allow_tenant_custom_groups: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + allow_tenant_custom_roles: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + allow_tenant_api_keys: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + +class TenantSettings(AccessBase, TimestampMixin): + __tablename__ = "access_tenant_settings" + + tenant_id: Mapped[str] = mapped_column(ForeignKey("access_tenants.id", ondelete="CASCADE"), primary_key=True) + allow_custom_groups: Mapped[bool | None] = mapped_column(Boolean) + allow_custom_roles: Mapped[bool | None] = mapped_column(Boolean) + allow_api_keys: Mapped[bool | None] = mapped_column(Boolean) + settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) diff --git a/src/govoplan_access/backend/db/session.py b/src/govoplan_access/backend/db/session.py new file mode 100644 index 0000000..4977466 --- /dev/null +++ b/src/govoplan_access/backend/db/session.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from collections.abc import Generator + +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker + +from govoplan_core.db.session import create_database_engine + + +def create_access_engine(database_url: str) -> Engine: + return create_database_engine(database_url) + + +def create_access_session_factory(database_url: str) -> sessionmaker[Session]: + engine = create_access_engine(database_url) + return sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False) + + +def session_scope(session_factory: sessionmaker[Session]) -> Generator[Session, None, None]: + with session_factory() as session: + yield session diff --git a/src/govoplan_access/backend/manifest.py b/src/govoplan_access/backend/manifest.py new file mode 100644 index 0000000..ccfc479 --- /dev/null +++ b/src/govoplan_access/backend/manifest.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from govoplan_access.backend.db.base import AccessBase +from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata +from govoplan_core.core.access import CAPABILITY_ACCESS_PERMISSION_EVALUATOR, CAPABILITY_ACCESS_PRINCIPAL_RESOLVER +from govoplan_core.core.modules import MigrationSpec, ModuleContext, ModuleManifest, PermissionDefinition, RoleTemplate + + +def _permission(scope: str, label: str, description: str, category: str, level: str) -> PermissionDefinition: + module_id, resource, action = scope.split(":", 2) + return PermissionDefinition( + scope=scope, + label=label, + description=description, + category=category, + level=level, # type: ignore[arg-type] + module_id=module_id, + resource=resource, + action=action, + ) + + +ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = ( + _permission("access:tenant:read", "View tenants", "List and inspect tenant registry entries.", "Access", "system"), + _permission("access:tenant:create", "Create tenants", "Create tenant registry entries.", "Access", "system"), + _permission("access:tenant:update", "Update tenants", "Update tenant metadata and activation state.", "Access", "system"), + _permission("access:account:read", "View accounts", "List and inspect global login accounts.", "Access", "system"), + _permission("access:account:create", "Create accounts", "Create global login accounts.", "Access", "system"), + _permission("access:account:update", "Update accounts", "Update or suspend global login accounts.", "Access", "system"), + _permission("access:membership:read", "View memberships", "List tenant memberships and effective access.", "Tenant access", "tenant"), + _permission("access:membership:create", "Create memberships", "Create tenant-local account memberships.", "Tenant access", "tenant"), + _permission("access:membership:update", "Update memberships", "Update or suspend tenant memberships.", "Tenant access", "tenant"), + _permission("access:group:read", "View groups", "List tenant groups and members.", "Tenant access", "tenant"), + _permission("access:group:write", "Manage groups", "Create and update tenant groups.", "Tenant access", "tenant"), + _permission("access:group:manage_members", "Manage group members", "Add and remove memberships from groups.", "Tenant access", "tenant"), + _permission("access:role:read", "View roles", "Inspect tenant and system role definitions.", "Tenant access", "tenant"), + _permission("access:role:write", "Manage roles", "Create and update assignable roles.", "Tenant access", "tenant"), + _permission("access:role:assign", "Assign roles", "Bind roles to memberships, groups, accounts or services.", "Tenant access", "tenant"), + _permission("access:api_key:read", "View API keys", "List API keys without revealing secrets.", "Tenant access", "tenant"), + _permission("access:api_key:create", "Create API keys", "Create tenant API keys within delegation limits.", "Tenant access", "tenant"), + _permission("access:api_key:revoke", "Revoke API keys", "Revoke tenant API keys.", "Tenant access", "tenant"), + _permission("access:setting:read", "View settings", "Read access and governance settings.", "Tenant access", "tenant"), + _permission("access:setting:write", "Manage settings", "Update access and governance settings.", "Tenant access", "tenant"), + _permission("access:governance:read", "View governance", "Inspect managed role and group templates.", "Access", "system"), + _permission("access:governance:write", "Manage governance", "Create and assign managed role and group templates.", "Access", "system"), +) + +ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = ( + RoleTemplate( + slug="system_owner", + name="System owner", + description="Protected full instance-wide administration.", + permissions=("system:*",), + level="system", + managed=True, + protected=True, + ), + RoleTemplate( + slug="system_admin", + name="System administrator", + description="Manage tenants, accounts, settings, and governance without protected owner status.", + permissions=( + "access:tenant:read", + "access:tenant:create", + "access:tenant:update", + "access:account:read", + "access:account:create", + "access:account:update", + "access:governance:read", + "access:governance:write", + ), + level="system", + managed=True, + protected=False, + ), + RoleTemplate( + slug="tenant_owner", + name="Tenant owner", + description="Protected full tenant administration and module access.", + permissions=("tenant:*",), + level="tenant", + managed=True, + protected=True, + ), + RoleTemplate( + slug="access_admin", + name="Access administrator", + description="Manage memberships, groups, roles, and API keys within delegation limits.", + permissions=( + "access:membership:read", + "access:membership:create", + "access:membership:update", + "access:group:read", + "access:group:write", + "access:group:manage_members", + "access:role:read", + "access:role:assign", + "access:api_key:read", + "access:api_key:create", + "access:api_key:revoke", + ), + level="tenant", + managed=True, + protected=False, + ), +) + + +def _legacy_principal_resolver(context: ModuleContext) -> object: + del context + from govoplan_core.auth.dependencies import LegacyPrincipalResolver + + return LegacyPrincipalResolver() + + +def _legacy_permission_evaluator(context: ModuleContext) -> object: + del context + from govoplan_core.auth.dependencies import LegacyPermissionEvaluator + + return LegacyPermissionEvaluator() + + +manifest = ModuleManifest( + id="access", + name="Access", + version="0.1.4", + permissions=ACCESS_PERMISSIONS, + role_templates=ACCESS_ROLE_TEMPLATES, + migration_spec=MigrationSpec(module_id="access", metadata=AccessBase.metadata), + capability_factories={ + CAPABILITY_ACCESS_PRINCIPAL_RESOLVER: _legacy_principal_resolver, + CAPABILITY_ACCESS_PERMISSION_EVALUATOR: _legacy_permission_evaluator, + }, +) + + +def get_manifest() -> ModuleManifest: + return manifest diff --git a/src/govoplan_access/backend/permissions/__init__.py b/src/govoplan_access/backend/permissions/__init__.py new file mode 100644 index 0000000..74957d6 --- /dev/null +++ b/src/govoplan_access/backend/permissions/__init__.py @@ -0,0 +1,2 @@ +"""Permission definitions, evaluation, and registry helpers.""" + diff --git a/src/govoplan_access/backend/permissions/definitions.py b/src/govoplan_access/backend/permissions/definitions.py new file mode 100644 index 0000000..f3ebd78 --- /dev/null +++ b/src/govoplan_access/backend/permissions/definitions.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from govoplan_core.core.modules import PermissionDefinition, PermissionLevel, RoleTemplate + +__all__ = ["PermissionDefinition", "PermissionLevel", "RoleTemplate"] + diff --git a/src/govoplan_access/backend/permissions/evaluator.py b/src/govoplan_access/backend/permissions/evaluator.py new file mode 100644 index 0000000..823f997 --- /dev/null +++ b/src/govoplan_access/backend/permissions/evaluator.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from collections.abc import Iterable, Mapping + +from govoplan_core.core.modules import PermissionDefinition + + +def scope_grants(granted: str, required: str, *, catalog: Mapping[str, PermissionDefinition] | None = None) -> bool: + if granted == required or granted == "*": + return True + if granted == "tenant:*": + if catalog is None: + return not required.startswith("system:") + definition = catalog.get(required) + return definition is not None and definition.level == "tenant" + if granted == "system:*": + if catalog is None: + return required.startswith("system:") + definition = catalog.get(required) + return definition is not None and definition.level == "system" + if granted.endswith(":*"): + return required.startswith(granted[:-1]) + return False + + +def scopes_grant( + scopes: Iterable[str], + required: str, + *, + catalog: Mapping[str, PermissionDefinition] | None = None, +) -> bool: + return any(scope_grants(scope, required, catalog=catalog) for scope in scopes) + + +def expand_scopes( + scopes: Iterable[str], + *, + catalog: Mapping[str, PermissionDefinition], + include_unknown: bool = False, +) -> list[str]: + expanded: set[str] = set() + for scope in {str(scope) for scope in scopes if scope}: + matched = {candidate for candidate in catalog if scope_grants(scope, candidate, catalog=catalog)} + if matched: + expanded.update(matched) + if scope.endswith(":*") or scope in {"*", "tenant:*", "system:*"}: + expanded.add(scope) + continue + if include_unknown: + expanded.add(scope) + return sorted(expanded) + diff --git a/src/govoplan_access/backend/permissions/registry.py b/src/govoplan_access/backend/permissions/registry.py new file mode 100644 index 0000000..908b09b --- /dev/null +++ b/src/govoplan_access/backend/permissions/registry.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from collections.abc import Iterable + +from govoplan_access.backend.permissions.evaluator import expand_scopes, scopes_grant +from govoplan_core.core.modules import PermissionDefinition + + +class PermissionRegistry: + def __init__(self, permissions: Iterable[PermissionDefinition] = ()) -> None: + self._catalog: dict[str, PermissionDefinition] = {} + for permission in permissions: + self.register(permission) + + @property + def catalog(self) -> dict[str, PermissionDefinition]: + return dict(self._catalog) + + def register(self, permission: PermissionDefinition) -> None: + if permission.scope in self._catalog: + raise ValueError(f"Duplicate permission scope: {permission.scope}") + self._catalog[permission.scope] = permission + + def has(self, scope: str) -> bool: + return scope in self._catalog + + def grants(self, scopes: Iterable[str], required: str) -> bool: + return scopes_grant(scopes, required, catalog=self._catalog) + + def expand(self, scopes: Iterable[str], *, include_unknown: bool = False) -> list[str]: + return expand_scopes(scopes, catalog=self._catalog, include_unknown=include_unknown) + + def validate_known(self, scopes: Iterable[str]) -> list[str]: + unknown = sorted(scope for scope in scopes if scope not in self._catalog and not scope.endswith(":*") and scope not in {"*", "tenant:*", "system:*"}) + if unknown: + raise ValueError("Unknown permission scope(s): " + ", ".join(unknown)) + return sorted(set(scopes)) diff --git a/src/govoplan_access/backend/tenancy/__init__.py b/src/govoplan_access/backend/tenancy/__init__.py new file mode 100644 index 0000000..abea063 --- /dev/null +++ b/src/govoplan_access/backend/tenancy/__init__.py @@ -0,0 +1,2 @@ +"""Tenant datastore routing abstractions.""" + diff --git a/src/govoplan_access/backend/tenancy/datastore.py b/src/govoplan_access/backend/tenancy/datastore.py new file mode 100644 index 0000000..28550f5 --- /dev/null +++ b/src/govoplan_access/backend/tenancy/datastore.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from collections.abc import Generator +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Literal, Protocol + +from sqlalchemy.orm import Session, sessionmaker + + +DatastoreMode = Literal["shared", "schema", "database"] + + +@dataclass(frozen=True, slots=True) +class TenantDatastoreRef: + tenant_id: str + mode: DatastoreMode = "shared" + datastore_id: str | None = None + schema_name: str | None = None + database_url_secret_ref: str | None = None + + +class TenantDatastore(Protocol): + ref: TenantDatastoreRef + + @contextmanager + def session_for(self, module_id: str) -> Generator[Session, None, None]: + ... + + +class DatastoreResolver(Protocol): + def for_tenant(self, tenant_id: str) -> TenantDatastore: + ... + + +class SharedTenantDatastore: + def __init__(self, ref: TenantDatastoreRef, session_factory: sessionmaker[Session]) -> None: + self.ref = ref + self._session_factory = session_factory + + @contextmanager + def session_for(self, module_id: str) -> Generator[Session, None, None]: + del module_id + with self._session_factory() as session: + yield session + + +class SharedDatastoreResolver: + def __init__(self, session_factory: sessionmaker[Session]) -> None: + self._session_factory = session_factory + + def for_tenant(self, tenant_id: str) -> TenantDatastore: + return SharedTenantDatastore(TenantDatastoreRef(tenant_id=tenant_id), self._session_factory) + diff --git a/src/govoplan_access/py.typed b/src/govoplan_access/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_access/py.typed @@ -0,0 +1 @@ +