Create access module package
This commit is contained in:
35
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
35
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
@@ -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:
|
||||||
1
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
1
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
27
.gitea/ISSUE_TEMPLATE/docs_workflow.md
Normal file
27
.gitea/ISSUE_TEMPLATE/docs_workflow.md
Normal file
@@ -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?
|
||||||
32
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
32
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
@@ -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:
|
||||||
28
.gitea/ISSUE_TEMPLATE/task.md
Normal file
28
.gitea/ISSUE_TEMPLATE/task.md
Normal file
@@ -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:
|
||||||
25
.gitea/ISSUE_TEMPLATE/tech_debt.md
Normal file
25
.gitea/ISSUE_TEMPLATE/tech_debt.md
Normal file
@@ -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:
|
||||||
15
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
15
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
## Issue
|
||||||
|
|
||||||
|
Closes #
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Follow-up issues:
|
||||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.eggs/
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
node_modules/
|
||||||
|
*.tsbuildinfo
|
||||||
28
AGENTS.md
Normal file
28
AGENTS.md
Normal file
@@ -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.
|
||||||
3
LICENSE
Normal file
3
LICENSE
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
Proprietary.
|
||||||
|
|
||||||
|
Copyright GovOPlaN contributors. All rights reserved.
|
||||||
56
README.md
Normal file
56
README.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
206
docs/gitea-labels.json
Normal file
206
docs/gitea-labels.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
25
pyproject.toml
Normal file
25
pyproject.toml
Normal file
@@ -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"
|
||||||
3
src/govoplan_access/__init__.py
Normal file
3
src/govoplan_access/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""GovOPlaN access platform module."""
|
||||||
|
|
||||||
|
__version__ = "0.1.4"
|
||||||
1
src/govoplan_access/backend/__init__.py
Normal file
1
src/govoplan_access/backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Backend integration for the GovOPlaN access module."""
|
||||||
2
src/govoplan_access/backend/auth/__init__.py
Normal file
2
src/govoplan_access/backend/auth/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Authentication and principal helpers for platform access."""
|
||||||
|
|
||||||
27
src/govoplan_access/backend/auth/principals.py
Normal file
27
src/govoplan_access/backend/auth/principals.py
Normal file
@@ -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)
|
||||||
81
src/govoplan_access/backend/auth/roles.py
Normal file
81
src/govoplan_access/backend/auth/roles.py
Normal file
@@ -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)
|
||||||
21
src/govoplan_access/backend/auth/tokens.py
Normal file
21
src/govoplan_access/backend/auth/tokens.py
Normal file
@@ -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)
|
||||||
|
|
||||||
2
src/govoplan_access/backend/db/__init__.py
Normal file
2
src/govoplan_access/backend/db/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Access-platform SQLAlchemy metadata and models."""
|
||||||
|
|
||||||
21
src/govoplan_access/backend/db/base.py
Normal file
21
src/govoplan_access/backend/db/base.py
Normal file
@@ -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"]
|
||||||
236
src/govoplan_access/backend/db/models.py
Normal file
236
src/govoplan_access/backend/db/models.py
Normal file
@@ -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)
|
||||||
22
src/govoplan_access/backend/db/session.py
Normal file
22
src/govoplan_access/backend/db/session.py
Normal file
@@ -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
|
||||||
138
src/govoplan_access/backend/manifest.py
Normal file
138
src/govoplan_access/backend/manifest.py
Normal file
@@ -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
|
||||||
2
src/govoplan_access/backend/permissions/__init__.py
Normal file
2
src/govoplan_access/backend/permissions/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Permission definitions, evaluation, and registry helpers."""
|
||||||
|
|
||||||
6
src/govoplan_access/backend/permissions/definitions.py
Normal file
6
src/govoplan_access/backend/permissions/definitions.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import PermissionDefinition, PermissionLevel, RoleTemplate
|
||||||
|
|
||||||
|
__all__ = ["PermissionDefinition", "PermissionLevel", "RoleTemplate"]
|
||||||
|
|
||||||
52
src/govoplan_access/backend/permissions/evaluator.py
Normal file
52
src/govoplan_access/backend/permissions/evaluator.py
Normal file
@@ -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)
|
||||||
|
|
||||||
37
src/govoplan_access/backend/permissions/registry.py
Normal file
37
src/govoplan_access/backend/permissions/registry.py
Normal file
@@ -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))
|
||||||
2
src/govoplan_access/backend/tenancy/__init__.py
Normal file
2
src/govoplan_access/backend/tenancy/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Tenant datastore routing abstractions."""
|
||||||
|
|
||||||
54
src/govoplan_access/backend/tenancy/datastore.py
Normal file
54
src/govoplan_access/backend/tenancy/datastore.py
Normal file
@@ -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)
|
||||||
|
|
||||||
1
src/govoplan_access/py.typed
Normal file
1
src/govoplan_access/py.typed
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
Reference in New Issue
Block a user