Compare commits
@@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"PQL_VAULT": "/mnt/media/Projects/core-api"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(pql)",
|
||||||
|
"Bash(pql *)",
|
||||||
|
"Bash(/home/jpmschweitzer/.local/bin/pql:*)",
|
||||||
|
"Bash(git status:*)",
|
||||||
|
"Bash(git log:*)",
|
||||||
|
"Bash(git diff:*)",
|
||||||
|
"Bash(git branch:*)",
|
||||||
|
"Bash(.venv/bin/python -m pytest:*)",
|
||||||
|
"Bash(.venv/bin/pytest:*)",
|
||||||
|
"Bash(pytest:*)",
|
||||||
|
"Bash(alembic *)",
|
||||||
|
"Bash(docker logs core-api:*)",
|
||||||
|
"Bash(curl -s http://localhost:8083/*)"
|
||||||
|
],
|
||||||
|
"deny": [
|
||||||
|
"Bash(/mnt/media/Projects/cladmin/ops/bin/toj)",
|
||||||
|
"Bash(/mnt/media/Projects/cladmin/ops/bin/toj:*)",
|
||||||
|
"Bash(alembic downgrade base*)",
|
||||||
|
"Bash(chmod -R 777 *)",
|
||||||
|
"Bash(chmod 777 *)",
|
||||||
|
"Bash(dd if=*)",
|
||||||
|
"Bash(find * -delete*)",
|
||||||
|
"Bash(find * -exec*)",
|
||||||
|
"Bash(git * add --all*)",
|
||||||
|
"Bash(git * add -A*)",
|
||||||
|
"Bash(git * add .)",
|
||||||
|
"Bash(git * branch -D *)",
|
||||||
|
"Bash(git * checkout -- *)",
|
||||||
|
"Bash(git * clean -fd*)",
|
||||||
|
"Bash(git * clean -fdx*)",
|
||||||
|
"Bash(git * commit --no-verify*)",
|
||||||
|
"Bash(git * merge --no-ff*)",
|
||||||
|
"Bash(git * push --force*)",
|
||||||
|
"Bash(git * push -f*)",
|
||||||
|
"Bash(git * reset --hard*)",
|
||||||
|
"Bash(git * restore .*)",
|
||||||
|
"Bash(git add --all*)",
|
||||||
|
"Bash(git add -A*)",
|
||||||
|
"Bash(git add .)",
|
||||||
|
"Bash(git branch -D *)",
|
||||||
|
"Bash(git checkout -- *)",
|
||||||
|
"Bash(git clean -fd*)",
|
||||||
|
"Bash(git clean -fdx*)",
|
||||||
|
"Bash(git commit --no-verify*)",
|
||||||
|
"Bash(git merge --no-ff*)",
|
||||||
|
"Bash(git push --force*)",
|
||||||
|
"Bash(git push -f*)",
|
||||||
|
"Bash(git reset --hard*)",
|
||||||
|
"Bash(git restore .*)",
|
||||||
|
"Bash(mkfs*)",
|
||||||
|
"Bash(psql * -c DROP*)",
|
||||||
|
"Bash(psql * DROP DATABASE*)",
|
||||||
|
"Bash(psql * TRUNCATE*)",
|
||||||
|
"Bash(rm -rf $HOME)",
|
||||||
|
"Bash(rm -rf /)",
|
||||||
|
"Bash(rm -rf ~)",
|
||||||
|
"Bash(su *)",
|
||||||
|
"Bash(sudo *)",
|
||||||
|
"Bash(toj)",
|
||||||
|
"Bash(toj:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-4
@@ -39,12 +39,9 @@ HOMEASSISTANT_URL=http://localhost:8123
|
|||||||
HOMEASSISTANT_TOKEN=your-long-lived-access-token
|
HOMEASSISTANT_TOKEN=your-long-lived-access-token
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# AI Services
|
# Search
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Ollama API
|
|
||||||
OLLAMA_BASE_URL=http://localhost:11434
|
|
||||||
|
|
||||||
# SearXNG (self-hosted search)
|
# SearXNG (self-hosted search)
|
||||||
SEARXNG_URL=http://localhost:8080
|
SEARXNG_URL=http://localhost:8080
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
.pql/changelog/*.sql merge=union
|
||||||
@@ -1,19 +1,32 @@
|
|||||||
name: Build and Push
|
name: Build and Push
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
push:
|
||||||
types: [published]
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Create Gitea Release
|
||||||
|
run: |
|
||||||
|
curl -sf -X POST \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"tag_name": "${{ github.ref_name }}", "name": "Release ${{ github.ref_name }}", "body": "Automated release for ${{ github.ref_name }}"}' \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||||
|
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: release
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Login to Gitea Registry
|
- name: Login to Gitea Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: git.schweitz.internal
|
registry: git.schweitz.net
|
||||||
username: ${{ secrets.REGISTRY_USER }}
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
@@ -23,8 +36,8 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
git.schweitz.internal/jpmschweitzer/core-api:latest
|
git.schweitz.net/jpmschweitzer/core-api:latest
|
||||||
git.schweitz.internal/jpmschweitzer/core-api:${{ github.ref_name }}
|
git.schweitz.net/jpmschweitzer/core-api:${{ github.ref_name }}
|
||||||
|
|
||||||
- name: Trigger Watchtower update
|
- name: Trigger Watchtower update
|
||||||
if: success()
|
if: success()
|
||||||
|
|||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Trigger only. The checks live in the Makefile, where they can be read, run by
|
||||||
|
# hand (`make pre-push`), and changed under review.
|
||||||
|
#
|
||||||
|
# This file is identical in every repo in this workspace, deliberately: the call
|
||||||
|
# surface is the same everywhere even though what each gate runs is not, so
|
||||||
|
# nobody has to read a repo to find out how to check it (D-27).
|
||||||
|
#
|
||||||
|
# Enable per clone with: git config core.hooksPath .githooks
|
||||||
|
# Never bypass with --no-verify. Suppress a specific finding deliberately
|
||||||
|
# instead, with a reason — see `make pre-push`.
|
||||||
|
set -euo pipefail
|
||||||
|
exec make -C "$(git rev-parse --show-toplevel)" pre-push
|
||||||
+17
@@ -136,3 +136,20 @@ Thumbs.db
|
|||||||
|
|
||||||
# profiling data
|
# profiling data
|
||||||
.prof
|
.prof
|
||||||
|
|
||||||
|
# Claude Code local settings (machine-specific, may contain credentials)
|
||||||
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# pql — ignore everything except the changelog, which is the replication log of
|
||||||
|
# record and must be committed for tickets to travel with the repo.
|
||||||
|
.pql/*
|
||||||
|
!.pql/changelog/
|
||||||
|
|
||||||
|
|
||||||
|
# pql shims planted by `pql init` into the dir core.hooksPath points at.
|
||||||
|
# Per-clone: each embeds the absolute path of the pql binary that planted it.
|
||||||
|
# Only .githooks/pre-push is shared.
|
||||||
|
.githooks/pre-commit
|
||||||
|
.githooks/post-merge
|
||||||
|
.githooks/post-checkout
|
||||||
|
.githooks/post-rewrite
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Changelog format marker, written by pql. Comments only: this file
|
||||||
|
-- is never executed — Import descends into the per-table directories
|
||||||
|
-- and does not read the changelog root.
|
||||||
|
--
|
||||||
|
-- A changelog carrying no marker is format 1, the shape that existed
|
||||||
|
-- before formats were versioned. An older format is migrated forward
|
||||||
|
-- by `pql plan upgrade` (and automatically from the post-merge hook);
|
||||||
|
-- a newer one is refused rather than replayed under rules this binary
|
||||||
|
-- does not know. See D-28 and docs/versions.md.
|
||||||
|
-- pql:changelog_format: 2.0.0
|
||||||
|
-- pql:written_by: 2.2.0
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
-- Auto-generated by pql init. CREATE TABLE statements
|
||||||
|
-- for the planning schema; per-table dir keeps the changelog
|
||||||
|
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||||
|
-- idempotent so running schema files from each directory in
|
||||||
|
-- replay order is harmless.
|
||||||
|
--
|
||||||
|
-- Importer parses the markers below to detect schema drift
|
||||||
|
-- between the producing pql version and the local one — a
|
||||||
|
-- bumped canonical_version means projection rules changed
|
||||||
|
-- and replay must refuse rather than silently corrupt state.
|
||||||
|
-- pql:created_by: 2.2.0
|
||||||
|
-- pql:canonical_version: 2
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decisions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
|
CHECK(status IN ('active','superseded','resolved','open')),
|
||||||
|
date TEXT,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||||
|
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
ref_type TEXT NOT NULL
|
||||||
|
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (source_id, target_id, ref_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||||
|
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||||
|
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||||
|
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||||
|
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||||
|
CREATE TABLE IF NOT EXISTS tickets (
|
||||||
|
record_id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||||
|
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||||
|
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||||
|
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||||
|
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||||
|
-- always inserts the configured default explicitly.
|
||||||
|
status TEXT NOT NULL DEFAULT 'backlog',
|
||||||
|
priority TEXT DEFAULT 'medium'
|
||||||
|
CHECK(priority IN ('critical','high','medium','low')),
|
||||||
|
assigned_to TEXT,
|
||||||
|
team TEXT,
|
||||||
|
decision_ref TEXT REFERENCES decisions(id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||||
|
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||||
|
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||||
|
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||||
|
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||||
|
ticket_id TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||||
|
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
field TEXT NOT NULL,
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
changed_by TEXT,
|
||||||
|
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT UNIQUE,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (ticket_record_id, label)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
-- Auto-generated by pql init. CREATE TABLE statements
|
||||||
|
-- for the planning schema; per-table dir keeps the changelog
|
||||||
|
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||||
|
-- idempotent so running schema files from each directory in
|
||||||
|
-- replay order is harmless.
|
||||||
|
--
|
||||||
|
-- Importer parses the markers below to detect schema drift
|
||||||
|
-- between the producing pql version and the local one — a
|
||||||
|
-- bumped canonical_version means projection rules changed
|
||||||
|
-- and replay must refuse rather than silently corrupt state.
|
||||||
|
-- pql:created_by: 2.2.0
|
||||||
|
-- pql:canonical_version: 2
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decisions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
|
CHECK(status IN ('active','superseded','resolved','open')),
|
||||||
|
date TEXT,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||||
|
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
ref_type TEXT NOT NULL
|
||||||
|
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (source_id, target_id, ref_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||||
|
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||||
|
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||||
|
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||||
|
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||||
|
CREATE TABLE IF NOT EXISTS tickets (
|
||||||
|
record_id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||||
|
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||||
|
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||||
|
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||||
|
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||||
|
-- always inserts the configured default explicitly.
|
||||||
|
status TEXT NOT NULL DEFAULT 'backlog',
|
||||||
|
priority TEXT DEFAULT 'medium'
|
||||||
|
CHECK(priority IN ('critical','high','medium','low')),
|
||||||
|
assigned_to TEXT,
|
||||||
|
team TEXT,
|
||||||
|
decision_ref TEXT REFERENCES decisions(id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||||
|
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||||
|
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||||
|
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||||
|
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||||
|
ticket_id TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||||
|
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
field TEXT NOT NULL,
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
changed_by TEXT,
|
||||||
|
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT UNIQUE,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (ticket_record_id, label)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6KV13SSS2KZP10SKTT903C', 'description', 'Caller authentication for service callers, the housekeeping routes locked behind it, and the `book` domain with migration 005 — the first half of the household''s Office storage. Driven by workspace T-166; the design is workspace D-64…D-72 (`cladmin/governance/decisions/office.md`), above all D-68 (the Book lives here), D-70 (the verbs) and D-71.
|
||||||
|
|
||||||
|
- `require_caller()`: the Office''s service key with a forwarded identity, the Scheduler''s key (dispatch only), else an Authentik JWT. On every new route and every housekeeping route — core-api is internet-facing at api.schweitz.net with no forward-auth, and `/housekeeping/devices` answers unauthenticated today.
|
||||||
|
- `src/domains/book/` and migration 005: records (note, task, decision, question, rejected), tags with system tags `work`/`personal`, links, attachments, append-only events.', 'Caller authentication for service callers, the housekeeping routes locked behind it, and the `book` domain with migration 005 — the first half of the household''s Office storage. Driven by workspace T-166; the design is workspace D-64…D-72 (`cladmin/governance/decisions/office.md`), above all D-68 (the Book lives here), D-70 (the verbs) and D-71.
|
||||||
|
|
||||||
|
- `require_caller()`: the Office''s service key with a forwarded identity, the Scheduler''s key (dispatch only), else an Authentik JWT. On every new route and every housekeeping route — core-api is internet-facing at api.schweitz.net with no forward-auth, and `/housekeeping/devices` answers unauthenticated today.
|
||||||
|
- `src/domains/book/` and migration 005: records (note, task, decision, question, rejected), tags with system tags `work`/`personal`, links, attachments, append-only events.
|
||||||
|
|
||||||
|
**Code done 2026-10-19**, commits `04ec54b`…`63feb88` (round core-api-T-1-001): `make test` 381 → 430 passed; migration 005 round-trips on `core_api_test`; production `core_api` still at 004; mutation-checked (key compare, the housekeeping lock, the append-only trigger). Follow-ups: T-2 (remaining verbs, short ids, scope tags — amends 005 before its first deploy), T-3 (alembic target). Held for the milestone.', NULL, '2026-10-19 09:20:45', '2026-10-19 09:20:45.376', '2026-10-19 09:20:45.376', NULL, 'f74c7130a534e2a97136986fb545eedd', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMY61H4P86X0TKGD3D37W', 'description', 'The rest of the Book''s verbs (workspace D-70), and two corrections to what T-1 built — all before the Book''s first deploy. Driven by workspace T-166/T-167.
|
||||||
|
|
||||||
|
**Corrections, found reviewing T-1 (2026-10-19):**
|
||||||
|
- **Outward ids.** Records use UUIDs, but the Office names items in words — "Task 12", "Note 40" (workspace D-67). Add a short sequential `number` per record (unique, never reused) and accept it wherever a UUID is accepted. Migration 005 has never run against production `core_api` (still at 004), so **amend 005** rather than adding 006 — and re-prove its round trip on `core_api_test`.
|
||||||
|
- **`work` and `personal` as tags.** `tag`/`untag` treat them as free text; they must map onto the `scope` column (tagging `work` sets scope work; untagging the only scope is refused with a code that names the fix), so the outward "baked-in tags" of D-69 hold.
|
||||||
|
|
||||||
|
**The remaining verbs:** `append`, `remind`, `reschedule` (batch), `undo` (this identity''s last change, from `book_events`), `purge` (from trash only), `redact` (scrubs a value from the record and its events — the one audited exception to the append-only trigger), `pin`/`unpin`, `check`/`uncheck`, `snooze`, `archive`/`unarchive`, `trash`/`restore`, `pause`/`resume` (a project and its descendants through `parent`; statuses saved in `paused_statuses` and restored exactly), `due`, `next` (filters energy, estimate, context tags, scope); batch forms for every note- and write-class verb; an idempotency key on `add`.', 'The rest of the Book''s verbs (workspace D-70), and two corrections to what T-1 built — all before the Book''s first deploy. Driven by workspace T-166/T-167.
|
||||||
|
|
||||||
|
**Corrections, found reviewing T-1 (2026-10-19):**
|
||||||
|
- **Outward ids.** Records use UUIDs, but the Office names items in words — "Task 12", "Note 40" (workspace D-67). Add a short sequential `number` per record (unique, never reused) and accept it wherever a UUID is accepted. Migration 005 has never run against production `core_api` (still at 004), so **amend 005** rather than adding 006 — and re-prove its round trip on `core_api_test`.
|
||||||
|
- **`work` and `personal` as tags.** `tag`/`untag` treat them as free text; they must map onto the `scope` column (tagging `work` sets scope work; untagging the only scope is refused with a code that names the fix), so the outward "baked-in tags" of D-69 hold.
|
||||||
|
|
||||||
|
**The remaining verbs:** `append`, `remind`, `reschedule` (batch), `undo` (this identity''s last change, from `book_events`), `purge` (from trash only), `redact` (scrubs a value from the record and its events — the one audited exception to the append-only trigger), `pin`/`unpin`, `check`/`uncheck`, `snooze`, `archive`/`unarchive`, `trash`/`restore`, `pause`/`resume` (a project and its descendants through `parent`; statuses saved in `paused_statuses` and restored exactly), `due`, `next` (filters energy, estimate, context tags, scope); batch forms for every note- and write-class verb; an idempotency key on `add`.
|
||||||
|
|
||||||
|
**Add an owner (D-73).** Book records need an `owner` — the tatlock tenant id from the token''s signed `tatlock_user` claim (production `jpmschweitzer`), forwarded by the Office — and every query scoped by it. Not in migration 005 as written; add it while amending 005. The Scribe''s tables (workspace T-176) carry the same owner on addresses.', NULL, '2026-10-19 10:36:41', '2026-10-19 10:36:41.675', '2026-10-19 10:36:41.675', NULL, '2f858098d03ce3b28a64f4dcbdbea26a', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMY61H4P86X0TKGD3D37W', 'description', 'The rest of the Book''s verbs (workspace D-70), and two corrections to what T-1 built — all before the Book''s first deploy. Driven by workspace T-166/T-167.
|
||||||
|
|
||||||
|
**Corrections, found reviewing T-1 (2026-10-19):**
|
||||||
|
- **Outward ids.** Records use UUIDs, but the Office names items in words — "Task 12", "Note 40" (workspace D-67). Add a short sequential `number` per record (unique, never reused) and accept it wherever a UUID is accepted. Migration 005 has never run against production `core_api` (still at 004), so **amend 005** rather than adding 006 — and re-prove its round trip on `core_api_test`.
|
||||||
|
- **`work` and `personal` as tags.** `tag`/`untag` treat them as free text; they must map onto the `scope` column (tagging `work` sets scope work; untagging the only scope is refused with a code that names the fix), so the outward "baked-in tags" of D-69 hold.
|
||||||
|
|
||||||
|
**The remaining verbs:** `append`, `remind`, `reschedule` (batch), `undo` (this identity''s last change, from `book_events`), `purge` (from trash only), `redact` (scrubs a value from the record and its events — the one audited exception to the append-only trigger), `pin`/`unpin`, `check`/`uncheck`, `snooze`, `archive`/`unarchive`, `trash`/`restore`, `pause`/`resume` (a project and its descendants through `parent`; statuses saved in `paused_statuses` and restored exactly), `due`, `next` (filters energy, estimate, context tags, scope); batch forms for every note- and write-class verb; an idempotency key on `add`.
|
||||||
|
|
||||||
|
**Add an owner (D-73).** Book records need an `owner` — the tatlock tenant id from the token''s signed `tatlock_user` claim (production `jpmschweitzer`), forwarded by the Office — and every query scoped by it. Not in migration 005 as written; add it while amending 005. The Scribe''s tables (workspace T-176) carry the same owner on addresses.', 'The rest of the Book''s verbs (workspace D-70), and two corrections to what T-1 built — all before the Book''s first deploy. Driven by workspace T-166/T-167.
|
||||||
|
|
||||||
|
**Corrections, found reviewing T-1 (2026-10-19):**
|
||||||
|
- **Outward ids.** Records use UUIDs, but the Office names items in words — "Task 12", "Note 40" (workspace D-67). Add a short sequential `number` per record (unique, never reused) and accept it wherever a UUID is accepted. Migration 005 has never run against production `core_api` (still at 004), so **amend 005** rather than adding 006 — and re-prove its round trip on `core_api_test`.
|
||||||
|
- **`work` and `personal` as tags.** `tag`/`untag` treat them as free text; they must map onto the `scope` column (tagging `work` sets scope work; untagging the only scope is refused with a code that names the fix), so the outward "baked-in tags" of D-69 hold.
|
||||||
|
|
||||||
|
**The remaining verbs:** `append`, `remind`, `reschedule` (batch), `undo` (this identity''s last change, from `book_events`), `purge` (from trash only), `redact` (scrubs a value from the record and its events — the one audited exception to the append-only trigger), `pin`/`unpin`, `check`/`uncheck`, `snooze`, `archive`/`unarchive`, `trash`/`restore`, `pause`/`resume` (a project and its descendants through `parent`; statuses saved in `paused_statuses` and restored exactly), `due`, `next` (filters energy, estimate, context tags, scope); batch forms for every note- and write-class verb; an idempotency key on `add`.
|
||||||
|
|
||||||
|
**Add an owner (D-73).** Book records need an `owner` — the tatlock tenant id from the token''s signed `tatlock_user` claim (production `jpmschweitzer`), forwarded by the Office — and every query scoped by it. Not in migration 005 as written; add it while amending 005. The Scribe''s tables (workspace T-176) carry the same owner on addresses.
|
||||||
|
|
||||||
|
**Isolation (D-73).** Every Book query and write filters on the owner tenant; a test proves an `llm_tester` caller cannot read, change, link to or close a `jpmschweitzer` record (and the reverse) — the error is `not_found`, so another tenant''s ids are not even confirmed to exist.', NULL, '2026-10-19 10:44:56', '2026-10-19 10:44:56.770', '2026-10-19 10:44:56.770', NULL, '5a9e8eb56b5634bdb34d36f9b5a6cdbf', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN7T48Z610867H891MZCVGA4', 'description', '`require_caller()` (T-1) learns who the Office''s caller is beyond a declared identity: the **consumer** (the Authentik client the request was signed in through, e.g. `office-claude`) and the **tenant** (the signed `tatlock_user` claim, e.g. `jpmschweitzer`, `llm_tester`). The Office has already verified the token; core-api trusts these only alongside the Office''s service key, as it does the identity. Driven by workspace T-185; design in workspace D-73.', '`require_caller()` (T-1) learns who the Office''s caller is beyond a declared identity: the **consumer** (the Authentik client the request was signed in through, e.g. `office-claude`) and the **tenant** (the signed `tatlock_user` claim, e.g. `jpmschweitzer`, `llm_tester`). The Office has already verified the token; core-api trusts these only alongside the Office''s service key, as it does the identity. Driven by workspace T-185; design in workspace D-73.
|
||||||
|
|
||||||
|
**Code done 2026-10-19** (round core-api-T-4-001): `0453d08`, `ce2ec61` — `Caller` carries consumer and tenant; the Office must send `X-Office-Consumer` and `X-Office-Tenant` on every call (400 `consumer_required`/`_invalid`, `tenant_required`/`_invalid`); other kinds ignore both headers; a JWT user''s tenant comes only from its own `tatlock_user` claim; the consumer lands in `book_events.client`. `make test` 430 → 451; mutation-checked (tenant requirement, header spoofing). Held — deploys with core-api T-1 at the milestone.', NULL, '2026-10-19 11:46:47', '2026-10-19 11:46:47.566', '2026-10-19 11:46:47.566', NULL, '1c452fa8a2ac5f5c326e9bf08e7981b6', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN7T48Z610867H891MZCVGA4', 'status', 'backlog', 'done', NULL, '2026-10-19 11:46:47', '2026-10-19 11:46:47.688', '2026-10-19 11:46:47.688', NULL, '877161504860711bfc61a86a6ed44e46', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMYNQF0VP25QMY7YTB41W', 'description', '`alembic/env.py` always takes its target from `get_settings().database_url`, composed from `.env`''s `POSTGRES_*` — which point at **production** `core_api`. `get_settings()` is `lru_cache`d and is warmed by anything that imports `src.main`, so a test that needs to migrate `core_api_test` has to run alembic in a subprocess with every `POSTGRES_*` overridden. T-1''s tests do exactly that, correctly — but one missed override migrates production.
|
||||||
|
|
||||||
|
Fix: honour `-x db_url=…` (or a `TEST_DATABASE_URL`-style variable) in `env.py` before falling back to settings, and have the migration tests use it, so the target is named rather than inherited. Found by the T-1 round''s friction report.', '`alembic/env.py` always takes its target from `get_settings().database_url`, composed from `.env`''s `POSTGRES_*` — which point at **production** `core_api`. `get_settings()` is `lru_cache`d and is warmed by anything that imports `src.main`, so a test that needs to migrate `core_api_test` has to run alembic in a subprocess with every `POSTGRES_*` overridden. T-1''s tests do exactly that, correctly — but one missed override migrates production.
|
||||||
|
|
||||||
|
Fix: honour `-x db_url=…` (or a `TEST_DATABASE_URL`-style variable) in `env.py` before falling back to settings, and have the migration tests use it, so the target is named rather than inherited. Found by the T-1 round''s friction report.
|
||||||
|
|
||||||
|
Related, from the T-4 round: there is no make target to start a dev server against `core_api_test` — the round built the `POSTGRES_*` overrides from `TEST_DATABASE_URL` by hand, carefully unprinted. A `make dev-test-server` that does it would remove a recurring hand-built step that one slip turns into a run against production.', NULL, '2026-10-19 11:46:47', '2026-10-19 11:46:47.827', '2026-10-19 11:46:47.827', NULL, '1cf3bed3f8970e8f70a0b78845c75c35', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8F5Z00KKNGTADK9BHG99N8', 'description', 'The messaging half of the Scribe, the household''s message bus: addresses, sending, mailboxes, history, search, read state and tags — enough for the first end-to-end flow through the Office. Driven by workspace T-186; design in workspace D-66 and D-73. Builds on T-1''s caller and T-4''s consumer and tenant.', 'The messaging half of the Scribe, the household''s message bus: addresses, sending, mailboxes, history, search, read state and tags — enough for the first end-to-end flow through the Office. Driven by workspace T-186; design in workspace D-66 and D-73. Builds on T-1''s caller and T-4''s consumer and tenant.
|
||||||
|
|
||||||
|
**Code done 2026-10-19** (round core-api-T-5-001): `ccdbc44`, `93b00b6` — migration 006 (`scribe_addresses` keyed on tenant + name, retained `scribe_messages` with full text, `scribe_mailbox_entries`), routes register, list, send, mailbox, history, find and one PATCH for mark/tag/untag; `from` stamped from the caller; every query filtered by tenant, another tenant''s ids answered as missing; an entry not addressed to the caller''s identity answers 404 even inside the tenant. `make test` 451 → 489; mutation-checked (tenant filter, from-stamping, ownership). Held for release. **The round printed the `core_api_test` password into its output; the lead rotated it** (role and `.env`, never printed; tests pass on the new one).', NULL, '2026-10-19 13:24:10', '2026-10-19 13:24:10.811', '2026-10-19 13:24:10.811', NULL, 'a43a09f6533c7433c348d6a15f669920', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8F5Z00KKNGTADK9BHG99N8', 'status', 'backlog', 'done', NULL, '2026-10-19 13:24:10', '2026-10-19 13:24:10.937', '2026-10-19 13:24:10.937', NULL, '07a38b83b95b5a7d62f1ef4704408375', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMYNQF0VP25QMY7YTB41W', 'description', '`alembic/env.py` always takes its target from `get_settings().database_url`, composed from `.env`''s `POSTGRES_*` — which point at **production** `core_api`. `get_settings()` is `lru_cache`d and is warmed by anything that imports `src.main`, so a test that needs to migrate `core_api_test` has to run alembic in a subprocess with every `POSTGRES_*` overridden. T-1''s tests do exactly that, correctly — but one missed override migrates production.
|
||||||
|
|
||||||
|
Fix: honour `-x db_url=…` (or a `TEST_DATABASE_URL`-style variable) in `env.py` before falling back to settings, and have the migration tests use it, so the target is named rather than inherited. Found by the T-1 round''s friction report.
|
||||||
|
|
||||||
|
Related, from the T-4 round: there is no make target to start a dev server against `core_api_test` — the round built the `POSTGRES_*` overrides from `TEST_DATABASE_URL` by hand, carefully unprinted. A `make dev-test-server` that does it would remove a recurring hand-built step that one slip turns into a run against production.', '`alembic/env.py` always takes its target from `get_settings().database_url`, composed from `.env`''s `POSTGRES_*` — which point at **production** `core_api`. `get_settings()` is `lru_cache`d and is warmed by anything that imports `src.main`, so a test that needs to migrate `core_api_test` has to run alembic in a subprocess with every `POSTGRES_*` overridden. T-1''s tests do exactly that, correctly — but one missed override migrates production.
|
||||||
|
|
||||||
|
Fix: honour `-x db_url=…` (or a `TEST_DATABASE_URL`-style variable) in `env.py` before falling back to settings, and have the migration tests use it, so the target is named rather than inherited. Found by the T-1 round''s friction report.
|
||||||
|
|
||||||
|
Related, from the T-4 round: there is no make target to start a dev server against `core_api_test` — the round built the `POSTGRES_*` overrides from `TEST_DATABASE_URL` by hand, carefully unprinted. A `make dev-test-server` that does it would remove a recurring hand-built step that one slip turns into a run against production.
|
||||||
|
|
||||||
|
Raised again by the T-5 round: every round rebuilds the `:8777` dev server against `core_api_test` by hand, and the T-5 round leaked the test password doing it. Make this target the next thing core-api does.', NULL, '2026-10-19 13:24:11', '2026-10-19 13:24:11.249', '2026-10-19 13:24:11.249', NULL, 'fe81a166cb0353f3a1e0747758c04e44', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMY61H4P86X0TKGD3D37W', 'description', 'The rest of the Book''s verbs (workspace D-70), and two corrections to what T-1 built — all before the Book''s first deploy. Driven by workspace T-166/T-167.
|
||||||
|
|
||||||
|
**Corrections, found reviewing T-1 (2026-10-19):**
|
||||||
|
- **Outward ids.** Records use UUIDs, but the Office names items in words — "Task 12", "Note 40" (workspace D-67). Add a short sequential `number` per record (unique, never reused) and accept it wherever a UUID is accepted. Migration 005 has never run against production `core_api` (still at 004), so **amend 005** rather than adding 006 — and re-prove its round trip on `core_api_test`.
|
||||||
|
- **`work` and `personal` as tags.** `tag`/`untag` treat them as free text; they must map onto the `scope` column (tagging `work` sets scope work; untagging the only scope is refused with a code that names the fix), so the outward "baked-in tags" of D-69 hold.
|
||||||
|
|
||||||
|
**The remaining verbs:** `append`, `remind`, `reschedule` (batch), `undo` (this identity''s last change, from `book_events`), `purge` (from trash only), `redact` (scrubs a value from the record and its events — the one audited exception to the append-only trigger), `pin`/`unpin`, `check`/`uncheck`, `snooze`, `archive`/`unarchive`, `trash`/`restore`, `pause`/`resume` (a project and its descendants through `parent`; statuses saved in `paused_statuses` and restored exactly), `due`, `next` (filters energy, estimate, context tags, scope); batch forms for every note- and write-class verb; an idempotency key on `add`.
|
||||||
|
|
||||||
|
**Add an owner (D-73).** Book records need an `owner` — the tatlock tenant id from the token''s signed `tatlock_user` claim (production `jpmschweitzer`), forwarded by the Office — and every query scoped by it. Not in migration 005 as written; add it while amending 005. The Scribe''s tables (workspace T-176) carry the same owner on addresses.
|
||||||
|
|
||||||
|
**Isolation (D-73).** Every Book query and write filters on the owner tenant; a test proves an `llm_tester` caller cannot read, change, link to or close a `jpmschweitzer` record (and the reverse) — the error is `not_found`, so another tenant''s ids are not even confirmed to exist.', 'The rest of the Book''s verbs (workspace D-70), and two corrections to what T-1 built — all before the Book''s first deploy. Driven by workspace T-166/T-167.
|
||||||
|
|
||||||
|
**Corrections, found reviewing T-1 (2026-10-19):**
|
||||||
|
- **Outward ids.** Records use UUIDs, but the Office names items in words — "Task 12", "Note 40" (workspace D-67). Add a short sequential `number` per record (unique, never reused) and accept it wherever a UUID is accepted. Migration 005 has never run against production `core_api` (still at 004), so **amend 005** rather than adding 006 — and re-prove its round trip on `core_api_test`.
|
||||||
|
- **`work` and `personal` as tags.** `tag`/`untag` treat them as free text; they must map onto the `scope` column (tagging `work` sets scope work; untagging the only scope is refused with a code that names the fix), so the outward "baked-in tags" of D-69 hold.
|
||||||
|
|
||||||
|
**The remaining verbs:** `append`, `remind`, `reschedule` (batch), `undo` (this identity''s last change, from `book_events`), `purge` (from trash only), `redact` (scrubs a value from the record and its events — the one audited exception to the append-only trigger), `pin`/`unpin`, `check`/`uncheck`, `snooze`, `archive`/`unarchive`, `trash`/`restore`, `pause`/`resume` (a project and its descendants through `parent`; statuses saved in `paused_statuses` and restored exactly), `due`, `next` (filters energy, estimate, context tags, scope); batch forms for every note- and write-class verb; an idempotency key on `add`.
|
||||||
|
|
||||||
|
**Add an owner (D-73).** Book records need an `owner` — the tatlock tenant id from the token''s signed `tatlock_user` claim (production `jpmschweitzer`), forwarded by the Office — and every query scoped by it. Not in migration 005 as written; add it while amending 005. The Scribe''s tables (workspace T-176) carry the same owner on addresses.
|
||||||
|
|
||||||
|
**Isolation (D-73).** Every Book query and write filters on the owner tenant; a test proves an `llm_tester` caller cannot read, change, link to or close a `jpmschweitzer` record (and the reverse) — the error is `not_found`, so another tenant''s ids are not even confirmed to exist.
|
||||||
|
|
||||||
|
**Split 2026-10-19:** the short outward ids and the owner tenant (amending 005), and the Book''s tenant filter, moved to **T-7**, so core-api can release safely. T-2 keeps the remaining verbs and work/personal as tags.', NULL, '2026-10-19 14:06:27', '2026-10-19 14:06:27.325', '2026-10-19 14:06:27.325', NULL, '0e233f93ee1e85162d05109749d33032', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8Z1FF1QDGR4QE4405AP8GC', 'status', 'backlog', 'done', NULL, '2026-10-19 14:51:47', '2026-10-19 14:51:47.496', '2026-10-19 14:51:47.496', NULL, '625ca10cfc9bcaf576a2fd52a3fe2792', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
-- Auto-generated by pql init. CREATE TABLE statements
|
||||||
|
-- for the planning schema; per-table dir keeps the changelog
|
||||||
|
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||||
|
-- idempotent so running schema files from each directory in
|
||||||
|
-- replay order is harmless.
|
||||||
|
--
|
||||||
|
-- Importer parses the markers below to detect schema drift
|
||||||
|
-- between the producing pql version and the local one — a
|
||||||
|
-- bumped canonical_version means projection rules changed
|
||||||
|
-- and replay must refuse rather than silently corrupt state.
|
||||||
|
-- pql:created_by: 2.2.0
|
||||||
|
-- pql:canonical_version: 2
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decisions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
|
CHECK(status IN ('active','superseded','resolved','open')),
|
||||||
|
date TEXT,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||||
|
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
ref_type TEXT NOT NULL
|
||||||
|
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (source_id, target_id, ref_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||||
|
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||||
|
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||||
|
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||||
|
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||||
|
CREATE TABLE IF NOT EXISTS tickets (
|
||||||
|
record_id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||||
|
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||||
|
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||||
|
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||||
|
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||||
|
-- always inserts the configured default explicitly.
|
||||||
|
status TEXT NOT NULL DEFAULT 'backlog',
|
||||||
|
priority TEXT DEFAULT 'medium'
|
||||||
|
CHECK(priority IN ('critical','high','medium','low')),
|
||||||
|
assigned_to TEXT,
|
||||||
|
team TEXT,
|
||||||
|
decision_ref TEXT REFERENCES decisions(id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||||
|
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||||
|
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||||
|
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||||
|
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||||
|
ticket_id TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||||
|
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
field TEXT NOT NULL,
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
changed_by TEXT,
|
||||||
|
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT UNIQUE,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (ticket_record_id, label)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6KV13SSS2KZP10SKTT903C', 'T-1', '2026-10-19 08:37:53.567', '2026-10-19 08:37:53.567', NULL, '624cc203712b63205f39eecd40c86d95', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMY61H4P86X0TKGD3D37W', 'T-2', '2026-10-19 09:20:45.104', '2026-10-19 09:20:45.104', NULL, '6d58bd9debd724db63e6067a82013b5f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMYNQF0VP25QMY7YTB41W', 'T-3', '2026-10-19 09:20:45.230', '2026-10-19 09:20:45.230', NULL, 'fdd572c5cfad3a68c1477ecede1db7f4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN7T48Z610867H891MZCVGA4', 'T-4', '2026-10-19 11:25:10.777', '2026-10-19 11:25:10.777', NULL, 'e30571204475f25d624a8d593829cda9', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8F5Z00KKNGTADK9BHG99N8', 'T-5', '2026-10-19 12:57:09.632', '2026-10-19 12:57:09.632', NULL, 'a8774787d37deb533e701914864fd560', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8NBWVQ53ZX7WMZNE5GJJP4', 'T-6', '2026-10-19 13:24:11.102', '2026-10-19 13:24:11.102', NULL, 'f6351f2d6190ae5fbd9e55e6eadc04e5', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8Z1FF1QDGR4QE4405AP8GC', 'T-7', '2026-10-19 14:06:27.193', '2026-10-19 14:06:27.193', NULL, '09267e8234ec21c6d75767d7cd21bb51', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN99CB552PSF82EE2P8PJZP0', 'T-8', '2026-10-19 14:51:37.641', '2026-10-19 14:51:37.641', NULL, 'd8c7f6122652cb1c3e448f1adedd54ab', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
-- Auto-generated by pql init. CREATE TABLE statements
|
||||||
|
-- for the planning schema; per-table dir keeps the changelog
|
||||||
|
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||||
|
-- idempotent so running schema files from each directory in
|
||||||
|
-- replay order is harmless.
|
||||||
|
--
|
||||||
|
-- Importer parses the markers below to detect schema drift
|
||||||
|
-- between the producing pql version and the local one — a
|
||||||
|
-- bumped canonical_version means projection rules changed
|
||||||
|
-- and replay must refuse rather than silently corrupt state.
|
||||||
|
-- pql:created_by: 2.2.0
|
||||||
|
-- pql:canonical_version: 2
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decisions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
|
CHECK(status IN ('active','superseded','resolved','open')),
|
||||||
|
date TEXT,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||||
|
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
ref_type TEXT NOT NULL
|
||||||
|
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (source_id, target_id, ref_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||||
|
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||||
|
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||||
|
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||||
|
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||||
|
CREATE TABLE IF NOT EXISTS tickets (
|
||||||
|
record_id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||||
|
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||||
|
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||||
|
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||||
|
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||||
|
-- always inserts the configured default explicitly.
|
||||||
|
status TEXT NOT NULL DEFAULT 'backlog',
|
||||||
|
priority TEXT DEFAULT 'medium'
|
||||||
|
CHECK(priority IN ('critical','high','medium','low')),
|
||||||
|
assigned_to TEXT,
|
||||||
|
team TEXT,
|
||||||
|
decision_ref TEXT REFERENCES decisions(id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||||
|
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||||
|
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||||
|
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||||
|
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||||
|
ticket_id TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||||
|
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
field TEXT NOT NULL,
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
changed_by TEXT,
|
||||||
|
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT UNIQUE,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (ticket_record_id, label)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
-- Auto-generated by pql init. CREATE TABLE statements
|
||||||
|
-- for the planning schema; per-table dir keeps the changelog
|
||||||
|
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||||
|
-- idempotent so running schema files from each directory in
|
||||||
|
-- replay order is harmless.
|
||||||
|
--
|
||||||
|
-- Importer parses the markers below to detect schema drift
|
||||||
|
-- between the producing pql version and the local one — a
|
||||||
|
-- bumped canonical_version means projection rules changed
|
||||||
|
-- and replay must refuse rather than silently corrupt state.
|
||||||
|
-- pql:created_by: 2.2.0
|
||||||
|
-- pql:canonical_version: 2
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decisions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
|
CHECK(status IN ('active','superseded','resolved','open')),
|
||||||
|
date TEXT,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||||
|
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
ref_type TEXT NOT NULL
|
||||||
|
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (source_id, target_id, ref_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||||
|
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||||
|
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||||
|
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||||
|
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||||
|
CREATE TABLE IF NOT EXISTS tickets (
|
||||||
|
record_id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||||
|
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||||
|
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||||
|
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||||
|
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||||
|
-- always inserts the configured default explicitly.
|
||||||
|
status TEXT NOT NULL DEFAULT 'backlog',
|
||||||
|
priority TEXT DEFAULT 'medium'
|
||||||
|
CHECK(priority IN ('critical','high','medium','low')),
|
||||||
|
assigned_to TEXT,
|
||||||
|
team TEXT,
|
||||||
|
decision_ref TEXT REFERENCES decisions(id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||||
|
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||||
|
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||||
|
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||||
|
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||||
|
ticket_id TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||||
|
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
field TEXT NOT NULL,
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
changed_by TEXT,
|
||||||
|
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT UNIQUE,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (ticket_record_id, label)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6KV13SSS2KZP10SKTT903C', 'task', NULL, 'Caller auth, locked housekeeping routes, and the Book domain', 'Caller authentication for service callers, the housekeeping routes locked behind it, and the `book` domain with migration 005 — the first half of the household''s Office storage. Driven by workspace T-166; the design is workspace D-64…D-72 (`cladmin/governance/decisions/office.md`), above all D-68 (the Book lives here), D-70 (the verbs) and D-71.
|
||||||
|
|
||||||
|
- `require_caller()`: the Office''s service key with a forwarded identity, the Scheduler''s key (dispatch only), else an Authentik JWT. On every new route and every housekeeping route — core-api is internet-facing at api.schweitz.net with no forward-auth, and `/housekeeping/devices` answers unauthenticated today.
|
||||||
|
- `src/domains/book/` and migration 005: records (note, task, decision, question, rejected), tags with system tags `work`/`personal`, links, attachments, append-only events.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 08:37:53.566', '2026-10-19 08:37:53.566', NULL, '2caf93d2159440ddba20ebe336115a3c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||||
|
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMY61H4P86X0TKGD3D37W', 'task', NULL, 'The Book''s remaining verbs, short outward ids, and work/personal as tags', 'The rest of the Book''s verbs (workspace D-70), and two corrections to what T-1 built — all before the Book''s first deploy. Driven by workspace T-166/T-167.
|
||||||
|
|
||||||
|
**Corrections, found reviewing T-1 (2026-10-19):**
|
||||||
|
- **Outward ids.** Records use UUIDs, but the Office names items in words — "Task 12", "Note 40" (workspace D-67). Add a short sequential `number` per record (unique, never reused) and accept it wherever a UUID is accepted. Migration 005 has never run against production `core_api` (still at 004), so **amend 005** rather than adding 006 — and re-prove its round trip on `core_api_test`.
|
||||||
|
- **`work` and `personal` as tags.** `tag`/`untag` treat them as free text; they must map onto the `scope` column (tagging `work` sets scope work; untagging the only scope is refused with a code that names the fix), so the outward "baked-in tags" of D-69 hold.
|
||||||
|
|
||||||
|
**The remaining verbs:** `append`, `remind`, `reschedule` (batch), `undo` (this identity''s last change, from `book_events`), `purge` (from trash only), `redact` (scrubs a value from the record and its events — the one audited exception to the append-only trigger), `pin`/`unpin`, `check`/`uncheck`, `snooze`, `archive`/`unarchive`, `trash`/`restore`, `pause`/`resume` (a project and its descendants through `parent`; statuses saved in `paused_statuses` and restored exactly), `due`, `next` (filters energy, estimate, context tags, scope); batch forms for every note- and write-class verb; an idempotency key on `add`.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 09:20:45.104', '2026-10-19 09:20:45.104', NULL, '9034c13962b3a44d6572e1e2d92e3ab3', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||||
|
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMYNQF0VP25QMY7YTB41W', 'task', NULL, 'Alembic takes its target from production settings; name it instead', '`alembic/env.py` always takes its target from `get_settings().database_url`, composed from `.env`''s `POSTGRES_*` — which point at **production** `core_api`. `get_settings()` is `lru_cache`d and is warmed by anything that imports `src.main`, so a test that needs to migrate `core_api_test` has to run alembic in a subprocess with every `POSTGRES_*` overridden. T-1''s tests do exactly that, correctly — but one missed override migrates production.
|
||||||
|
|
||||||
|
Fix: honour `-x db_url=…` (or a `TEST_DATABASE_URL`-style variable) in `env.py` before falling back to settings, and have the migration tests use it, so the target is named rather than inherited. Found by the T-1 round''s friction report.', 'backlog', 'medium', NULL, NULL, NULL, '2026-10-19 09:20:45.229', '2026-10-19 09:20:45.229', NULL, 'b096eb96b790b241405fe4703d4911af', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||||
|
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6KV13SSS2KZP10SKTT903C', 'task', NULL, 'Caller auth, locked housekeeping routes, and the Book domain', 'Caller authentication for service callers, the housekeeping routes locked behind it, and the `book` domain with migration 005 — the first half of the household''s Office storage. Driven by workspace T-166; the design is workspace D-64…D-72 (`cladmin/governance/decisions/office.md`), above all D-68 (the Book lives here), D-70 (the verbs) and D-71.
|
||||||
|
|
||||||
|
- `require_caller()`: the Office''s service key with a forwarded identity, the Scheduler''s key (dispatch only), else an Authentik JWT. On every new route and every housekeeping route — core-api is internet-facing at api.schweitz.net with no forward-auth, and `/housekeeping/devices` answers unauthenticated today.
|
||||||
|
- `src/domains/book/` and migration 005: records (note, task, decision, question, rejected), tags with system tags `work`/`personal`, links, attachments, append-only events.
|
||||||
|
|
||||||
|
**Code done 2026-10-19**, commits `04ec54b`…`63feb88` (round core-api-T-1-001): `make test` 381 → 430 passed; migration 005 round-trips on `core_api_test`; production `core_api` still at 004; mutation-checked (key compare, the housekeeping lock, the append-only trigger). Follow-ups: T-2 (remaining verbs, short ids, scope tags — amends 005 before its first deploy), T-3 (alembic target). Held for the milestone.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 08:37:53.566', '2026-10-19 09:20:45.375', NULL, '27ad92892863649997bb7a0ca147cbed', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||||
|
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMY61H4P86X0TKGD3D37W', 'task', NULL, 'The Book''s remaining verbs, short outward ids, and work/personal as tags', 'The rest of the Book''s verbs (workspace D-70), and two corrections to what T-1 built — all before the Book''s first deploy. Driven by workspace T-166/T-167.
|
||||||
|
|
||||||
|
**Corrections, found reviewing T-1 (2026-10-19):**
|
||||||
|
- **Outward ids.** Records use UUIDs, but the Office names items in words — "Task 12", "Note 40" (workspace D-67). Add a short sequential `number` per record (unique, never reused) and accept it wherever a UUID is accepted. Migration 005 has never run against production `core_api` (still at 004), so **amend 005** rather than adding 006 — and re-prove its round trip on `core_api_test`.
|
||||||
|
- **`work` and `personal` as tags.** `tag`/`untag` treat them as free text; they must map onto the `scope` column (tagging `work` sets scope work; untagging the only scope is refused with a code that names the fix), so the outward "baked-in tags" of D-69 hold.
|
||||||
|
|
||||||
|
**The remaining verbs:** `append`, `remind`, `reschedule` (batch), `undo` (this identity''s last change, from `book_events`), `purge` (from trash only), `redact` (scrubs a value from the record and its events — the one audited exception to the append-only trigger), `pin`/`unpin`, `check`/`uncheck`, `snooze`, `archive`/`unarchive`, `trash`/`restore`, `pause`/`resume` (a project and its descendants through `parent`; statuses saved in `paused_statuses` and restored exactly), `due`, `next` (filters energy, estimate, context tags, scope); batch forms for every note- and write-class verb; an idempotency key on `add`.
|
||||||
|
|
||||||
|
**Add an owner (D-73).** Book records need an `owner` — the tatlock tenant id from the token''s signed `tatlock_user` claim (production `jpmschweitzer`), forwarded by the Office — and every query scoped by it. Not in migration 005 as written; add it while amending 005. The Scribe''s tables (workspace T-176) carry the same owner on addresses.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 09:20:45.104', '2026-10-19 10:36:41.674', NULL, '39efc332e98d1f72eff1763b33125b19', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||||
|
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMY61H4P86X0TKGD3D37W', 'task', NULL, 'The Book''s remaining verbs, short outward ids, and work/personal as tags', 'The rest of the Book''s verbs (workspace D-70), and two corrections to what T-1 built — all before the Book''s first deploy. Driven by workspace T-166/T-167.
|
||||||
|
|
||||||
|
**Corrections, found reviewing T-1 (2026-10-19):**
|
||||||
|
- **Outward ids.** Records use UUIDs, but the Office names items in words — "Task 12", "Note 40" (workspace D-67). Add a short sequential `number` per record (unique, never reused) and accept it wherever a UUID is accepted. Migration 005 has never run against production `core_api` (still at 004), so **amend 005** rather than adding 006 — and re-prove its round trip on `core_api_test`.
|
||||||
|
- **`work` and `personal` as tags.** `tag`/`untag` treat them as free text; they must map onto the `scope` column (tagging `work` sets scope work; untagging the only scope is refused with a code that names the fix), so the outward "baked-in tags" of D-69 hold.
|
||||||
|
|
||||||
|
**The remaining verbs:** `append`, `remind`, `reschedule` (batch), `undo` (this identity''s last change, from `book_events`), `purge` (from trash only), `redact` (scrubs a value from the record and its events — the one audited exception to the append-only trigger), `pin`/`unpin`, `check`/`uncheck`, `snooze`, `archive`/`unarchive`, `trash`/`restore`, `pause`/`resume` (a project and its descendants through `parent`; statuses saved in `paused_statuses` and restored exactly), `due`, `next` (filters energy, estimate, context tags, scope); batch forms for every note- and write-class verb; an idempotency key on `add`.
|
||||||
|
|
||||||
|
**Add an owner (D-73).** Book records need an `owner` — the tatlock tenant id from the token''s signed `tatlock_user` claim (production `jpmschweitzer`), forwarded by the Office — and every query scoped by it. Not in migration 005 as written; add it while amending 005. The Scribe''s tables (workspace T-176) carry the same owner on addresses.
|
||||||
|
|
||||||
|
**Isolation (D-73).** Every Book query and write filters on the owner tenant; a test proves an `llm_tester` caller cannot read, change, link to or close a `jpmschweitzer` record (and the reverse) — the error is `not_found`, so another tenant''s ids are not even confirmed to exist.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 09:20:45.104', '2026-10-19 10:44:56.770', NULL, 'd930f0dbc1197dff3600619b31a6b0c6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||||
|
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN7T48Z610867H891MZCVGA4', 'task', NULL, 'The caller carries consumer and tenant from the Office', '`require_caller()` (T-1) learns who the Office''s caller is beyond a declared identity: the **consumer** (the Authentik client the request was signed in through, e.g. `office-claude`) and the **tenant** (the signed `tatlock_user` claim, e.g. `jpmschweitzer`, `llm_tester`). The Office has already verified the token; core-api trusts these only alongside the Office''s service key, as it does the identity. Driven by workspace T-185; design in workspace D-73.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 11:25:10.777', '2026-10-19 11:25:10.777', NULL, 'e73c46c5e77917c049e92ff1f627f447', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||||
|
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN7T48Z610867H891MZCVGA4', 'task', NULL, 'The caller carries consumer and tenant from the Office', '`require_caller()` (T-1) learns who the Office''s caller is beyond a declared identity: the **consumer** (the Authentik client the request was signed in through, e.g. `office-claude`) and the **tenant** (the signed `tatlock_user` claim, e.g. `jpmschweitzer`, `llm_tester`). The Office has already verified the token; core-api trusts these only alongside the Office''s service key, as it does the identity. Driven by workspace T-185; design in workspace D-73.
|
||||||
|
|
||||||
|
**Code done 2026-10-19** (round core-api-T-4-001): `0453d08`, `ce2ec61` — `Caller` carries consumer and tenant; the Office must send `X-Office-Consumer` and `X-Office-Tenant` on every call (400 `consumer_required`/`_invalid`, `tenant_required`/`_invalid`); other kinds ignore both headers; a JWT user''s tenant comes only from its own `tatlock_user` claim; the consumer lands in `book_events.client`. `make test` 430 → 451; mutation-checked (tenant requirement, header spoofing). Held — deploys with core-api T-1 at the milestone.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 11:25:10.777', '2026-10-19 11:46:47.566', NULL, 'ed07dd9dcd17801b51cbbf8cb3145451', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||||
|
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN7T48Z610867H891MZCVGA4', 'task', NULL, 'The caller carries consumer and tenant from the Office', '`require_caller()` (T-1) learns who the Office''s caller is beyond a declared identity: the **consumer** (the Authentik client the request was signed in through, e.g. `office-claude`) and the **tenant** (the signed `tatlock_user` claim, e.g. `jpmschweitzer`, `llm_tester`). The Office has already verified the token; core-api trusts these only alongside the Office''s service key, as it does the identity. Driven by workspace T-185; design in workspace D-73.
|
||||||
|
|
||||||
|
**Code done 2026-10-19** (round core-api-T-4-001): `0453d08`, `ce2ec61` — `Caller` carries consumer and tenant; the Office must send `X-Office-Consumer` and `X-Office-Tenant` on every call (400 `consumer_required`/`_invalid`, `tenant_required`/`_invalid`); other kinds ignore both headers; a JWT user''s tenant comes only from its own `tatlock_user` claim; the consumer lands in `book_events.client`. `make test` 430 → 451; mutation-checked (tenant requirement, header spoofing). Held — deploys with core-api T-1 at the milestone.', 'done', 'high', NULL, NULL, NULL, '2026-10-19 11:25:10.777', '2026-10-19 11:46:47.688', NULL, '4fbb627f0d1f48350ebd4d97fc64fd94', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||||
|
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMYNQF0VP25QMY7YTB41W', 'task', NULL, 'Alembic takes its target from production settings; name it instead', '`alembic/env.py` always takes its target from `get_settings().database_url`, composed from `.env`''s `POSTGRES_*` — which point at **production** `core_api`. `get_settings()` is `lru_cache`d and is warmed by anything that imports `src.main`, so a test that needs to migrate `core_api_test` has to run alembic in a subprocess with every `POSTGRES_*` overridden. T-1''s tests do exactly that, correctly — but one missed override migrates production.
|
||||||
|
|
||||||
|
Fix: honour `-x db_url=…` (or a `TEST_DATABASE_URL`-style variable) in `env.py` before falling back to settings, and have the migration tests use it, so the target is named rather than inherited. Found by the T-1 round''s friction report.
|
||||||
|
|
||||||
|
Related, from the T-4 round: there is no make target to start a dev server against `core_api_test` — the round built the `POSTGRES_*` overrides from `TEST_DATABASE_URL` by hand, carefully unprinted. A `make dev-test-server` that does it would remove a recurring hand-built step that one slip turns into a run against production.', 'backlog', 'medium', NULL, NULL, NULL, '2026-10-19 09:20:45.229', '2026-10-19 11:46:47.827', NULL, '3d53cf5d73b33f32d80232456e23f5ad', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||||
|
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8F5Z00KKNGTADK9BHG99N8', 'task', NULL, 'The Scribe''s messaging: addresses, send, mailbox, history, find, mark, tag', 'The messaging half of the Scribe, the household''s message bus: addresses, sending, mailboxes, history, search, read state and tags — enough for the first end-to-end flow through the Office. Driven by workspace T-186; design in workspace D-66 and D-73. Builds on T-1''s caller and T-4''s consumer and tenant.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 12:57:09.632', '2026-10-19 12:57:09.632', NULL, 'ae66ca9641212daeb46527917c324175', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||||
|
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8F5Z00KKNGTADK9BHG99N8', 'task', NULL, 'The Scribe''s messaging: addresses, send, mailbox, history, find, mark, tag', 'The messaging half of the Scribe, the household''s message bus: addresses, sending, mailboxes, history, search, read state and tags — enough for the first end-to-end flow through the Office. Driven by workspace T-186; design in workspace D-66 and D-73. Builds on T-1''s caller and T-4''s consumer and tenant.
|
||||||
|
|
||||||
|
**Code done 2026-10-19** (round core-api-T-5-001): `ccdbc44`, `93b00b6` — migration 006 (`scribe_addresses` keyed on tenant + name, retained `scribe_messages` with full text, `scribe_mailbox_entries`), routes register, list, send, mailbox, history, find and one PATCH for mark/tag/untag; `from` stamped from the caller; every query filtered by tenant, another tenant''s ids answered as missing; an entry not addressed to the caller''s identity answers 404 even inside the tenant. `make test` 451 → 489; mutation-checked (tenant filter, from-stamping, ownership). Held for release. **The round printed the `core_api_test` password into its output; the lead rotated it** (role and `.env`, never printed; tests pass on the new one).', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 12:57:09.632', '2026-10-19 13:24:10.810', NULL, '5351a56b3c5e6fb9594f0b2d87717774', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||||
|
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8F5Z00KKNGTADK9BHG99N8', 'task', NULL, 'The Scribe''s messaging: addresses, send, mailbox, history, find, mark, tag', 'The messaging half of the Scribe, the household''s message bus: addresses, sending, mailboxes, history, search, read state and tags — enough for the first end-to-end flow through the Office. Driven by workspace T-186; design in workspace D-66 and D-73. Builds on T-1''s caller and T-4''s consumer and tenant.
|
||||||
|
|
||||||
|
**Code done 2026-10-19** (round core-api-T-5-001): `ccdbc44`, `93b00b6` — migration 006 (`scribe_addresses` keyed on tenant + name, retained `scribe_messages` with full text, `scribe_mailbox_entries`), routes register, list, send, mailbox, history, find and one PATCH for mark/tag/untag; `from` stamped from the caller; every query filtered by tenant, another tenant''s ids answered as missing; an entry not addressed to the caller''s identity answers 404 even inside the tenant. `make test` 451 → 489; mutation-checked (tenant filter, from-stamping, ownership). Held for release. **The round printed the `core_api_test` password into its output; the lead rotated it** (role and `.env`, never printed; tests pass on the new one).', 'done', 'high', NULL, NULL, NULL, '2026-10-19 12:57:09.632', '2026-10-19 13:24:10.937', NULL, 'a75c4791f0cf9c91a56afc65e474bd43', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||||
|
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8NBWVQ53ZX7WMZNE5GJJP4', 'bug', NULL, 'User-kind callers have no consumer, so their address ownership matches any other''s', 'Callers of kind `user` (an Authentik user straight to core-api, e.g. tatlock-ui later) carry no consumer, so `scribe_addresses.owner_consumer` is `NULL` for anything they register — and ownership checks compare consumers, so two different user-kind callers in one tenant would each pass as owner of the other''s addresses (`None == None`). `book_events.client` has the same nullable shape. Not reachable through the Office (every Office call carries a real consumer since T-4), so not on the messaging slice''s path — but it must be closed before any user-kind caller registers or sends. Fix: give a user-kind caller a consumer of its own (e.g. `user:<preferred_username>`), or refuse address ownership to callers without one. Found in the T-5 round''s report.', 'backlog', 'medium', NULL, NULL, NULL, '2026-10-19 13:24:11.102', '2026-10-19 13:24:11.102', NULL, 'd7e0fab068383f77f9a0dd0b7b421a92', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||||
|
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMYNQF0VP25QMY7YTB41W', 'task', NULL, 'Alembic takes its target from production settings; name it instead', '`alembic/env.py` always takes its target from `get_settings().database_url`, composed from `.env`''s `POSTGRES_*` — which point at **production** `core_api`. `get_settings()` is `lru_cache`d and is warmed by anything that imports `src.main`, so a test that needs to migrate `core_api_test` has to run alembic in a subprocess with every `POSTGRES_*` overridden. T-1''s tests do exactly that, correctly — but one missed override migrates production.
|
||||||
|
|
||||||
|
Fix: honour `-x db_url=…` (or a `TEST_DATABASE_URL`-style variable) in `env.py` before falling back to settings, and have the migration tests use it, so the target is named rather than inherited. Found by the T-1 round''s friction report.
|
||||||
|
|
||||||
|
Related, from the T-4 round: there is no make target to start a dev server against `core_api_test` — the round built the `POSTGRES_*` overrides from `TEST_DATABASE_URL` by hand, carefully unprinted. A `make dev-test-server` that does it would remove a recurring hand-built step that one slip turns into a run against production.
|
||||||
|
|
||||||
|
Raised again by the T-5 round: every round rebuilds the `:8777` dev server against `core_api_test` by hand, and the T-5 round leaked the test password doing it. Make this target the next thing core-api does.', 'backlog', 'medium', NULL, NULL, NULL, '2026-10-19 09:20:45.229', '2026-10-19 13:24:11.248', NULL, '54942cb45b6635f1d21a053e8a8e25c6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||||
|
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8Z1FF1QDGR4QE4405AP8GC', 'task', NULL, 'The Book''s final schema and tenant boundary, before 005''s first release', 'The Book''s final schema and its tenant boundary, before core-api''s first release applies migration 005 to production. Split from T-2 (whose remaining verbs stay there). Amend 005 — never run in production — rather than add a migration: per-tenant short outward numbers ("Task 12", workspace D-67), an owner tenant on every record (D-73), and every Book route filtered by it, so the routes are safe to expose the moment they deploy.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 14:06:27.192', '2026-10-19 14:06:27.192', NULL, 'cd22ba2ca9a28b7690305eda1f8153b4', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||||
|
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMY61H4P86X0TKGD3D37W', 'task', NULL, 'The Book''s remaining verbs, short outward ids, and work/personal as tags', 'The rest of the Book''s verbs (workspace D-70), and two corrections to what T-1 built — all before the Book''s first deploy. Driven by workspace T-166/T-167.
|
||||||
|
|
||||||
|
**Corrections, found reviewing T-1 (2026-10-19):**
|
||||||
|
- **Outward ids.** Records use UUIDs, but the Office names items in words — "Task 12", "Note 40" (workspace D-67). Add a short sequential `number` per record (unique, never reused) and accept it wherever a UUID is accepted. Migration 005 has never run against production `core_api` (still at 004), so **amend 005** rather than adding 006 — and re-prove its round trip on `core_api_test`.
|
||||||
|
- **`work` and `personal` as tags.** `tag`/`untag` treat them as free text; they must map onto the `scope` column (tagging `work` sets scope work; untagging the only scope is refused with a code that names the fix), so the outward "baked-in tags" of D-69 hold.
|
||||||
|
|
||||||
|
**The remaining verbs:** `append`, `remind`, `reschedule` (batch), `undo` (this identity''s last change, from `book_events`), `purge` (from trash only), `redact` (scrubs a value from the record and its events — the one audited exception to the append-only trigger), `pin`/`unpin`, `check`/`uncheck`, `snooze`, `archive`/`unarchive`, `trash`/`restore`, `pause`/`resume` (a project and its descendants through `parent`; statuses saved in `paused_statuses` and restored exactly), `due`, `next` (filters energy, estimate, context tags, scope); batch forms for every note- and write-class verb; an idempotency key on `add`.
|
||||||
|
|
||||||
|
**Add an owner (D-73).** Book records need an `owner` — the tatlock tenant id from the token''s signed `tatlock_user` claim (production `jpmschweitzer`), forwarded by the Office — and every query scoped by it. Not in migration 005 as written; add it while amending 005. The Scribe''s tables (workspace T-176) carry the same owner on addresses.
|
||||||
|
|
||||||
|
**Isolation (D-73).** Every Book query and write filters on the owner tenant; a test proves an `llm_tester` caller cannot read, change, link to or close a `jpmschweitzer` record (and the reverse) — the error is `not_found`, so another tenant''s ids are not even confirmed to exist.
|
||||||
|
|
||||||
|
**Split 2026-10-19:** the short outward ids and the owner tenant (amending 005), and the Book''s tenant filter, moved to **T-7**, so core-api can release safely. T-2 keeps the remaining verbs and work/personal as tags.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 09:20:45.104', '2026-10-19 14:06:27.324', NULL, '58063dda134a948a461cd97325286e48', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||||
|
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN99CB552PSF82EE2P8PJZP0', 'story', NULL, 'The tenant registry: tenants, consumer grants, and one enforcement point', 'The tenant registry: one table that says which tenants exist, which consumers may act in each, and whether a tenant is active — the anchor every service''s tenancy conforms to (workspace D-73).
|
||||||
|
|
||||||
|
Today a tenant is an unchecked string: the Office header or a JWT''s `tatlock_user` claim is trusted as-is, and the Book and the Scribe file rows under whatever value arrives. A typo''d claim opens a fresh, empty namespace instead of being refused.
|
||||||
|
|
||||||
|
**Tables (migration 007):**
|
||||||
|
- `tenants` — `id` (the slug, the value of `tatlock_user`; same pattern `_require_office_tenant` accepts), `display_name`, `kind` (`household` | `person` | `test`), `status` (`active` | `suspended`), timestamps. Seeded: `jpmschweitzer` (household), `llm_tester` (test).
|
||||||
|
- `tenant_consumers` — a grant: consumer X may act in tenant Y. PK `(tenant_id, consumer)`. Seeded: `office-claude` in both seeded tenants.
|
||||||
|
- Foreign keys from every `owner_tenant` column in the Book and the Scribe to `tenants.id`.
|
||||||
|
|
||||||
|
**Enforcement:** every tenant-scoped operation goes through one helper. No tenant → 400 `tenant_required`. Tenant unknown → 403 `tenant_unknown`. Tenant suspended → 403 `tenant_suspended`. Office caller whose consumer holds no grant in that tenant → 403 `consumer_not_granted`.
|
||||||
|
|
||||||
|
**Read API, for other services to conform to:** `GET /tenants/me` (the caller''s own tenant and its consumer''s grant); `GET /tenants` (service callers without a tenant see all; a tenant-bearing caller sees only its own — the hard boundary).
|
||||||
|
|
||||||
|
**Not in v1:** write routes for provisioning (T-195 on the workspace board adds them, or an operator verb); per-consumer verb classes (workspace T-184 — a column on `tenant_consumers` when it lands).', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 14:51:37.641', '2026-10-19 14:51:37.641', NULL, '756a467f5dc4a84c7ee9080ef21ca5fb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||||
|
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8Z1FF1QDGR4QE4405AP8GC', 'task', NULL, 'The Book''s final schema and tenant boundary, before 005''s first release', 'The Book''s final schema and its tenant boundary, before core-api''s first release applies migration 005 to production. Split from T-2 (whose remaining verbs stay there). Amend 005 — never run in production — rather than add a migration: per-tenant short outward numbers ("Task 12", workspace D-67), an owner tenant on every record (D-73), and every Book route filtered by it, so the routes are safe to expose the moment they deploy.', 'done', 'high', NULL, NULL, NULL, '2026-10-19 14:06:27.192', '2026-10-19 14:51:47.496', NULL, '8797eb16319aa33c8317b0a1f0defc3d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
|
|
||||||
# AGENTS.md
|
|
||||||
|
|
||||||
> **Start every session by reading this file.**
|
|
||||||
> This file outlines the operational protocols, coding standards, and architectural decisions for this FastAPI project.
|
|
||||||
|
|
||||||
## 1. Agent Operational Protocols
|
|
||||||
|
|
||||||
### 🧠 Work Patterns (Plan-Act-Reflect)
|
|
||||||
* **Plan:** Before writing code, briefly outline your plan. Identify which files you will touch and what the side effects might be.
|
|
||||||
* **Act:** Execute the changes in small, atomic steps.
|
|
||||||
* **Reflect:** After coding, verify your work. Did you break existing tests? Did you add new tests?
|
|
||||||
|
|
||||||
### 🛡️ Git Discipline
|
|
||||||
* **NEVER commit to `main` or `master` directly.** Always create a feature branch: `feature/your-feature-name` or `fix/issue-description`.
|
|
||||||
* **Commit Messages:** Use the [Conventional Commits](https://www.conventionalcommits.org/) format.
|
|
||||||
* `feat: add user login endpoint`
|
|
||||||
* `fix: resolve database connection timeout`
|
|
||||||
* `refactor: split monolith dependency file`
|
|
||||||
* **Atomic Commits:** Keep commits small. One logical change = one commit.
|
|
||||||
|
|
||||||
### 📝 Changelog Maintenance
|
|
||||||
* **Update `CHANGELOG.md`** with every user-facing change.
|
|
||||||
* Format: `## [Unreleased] - YYYY-MM-DD` followed by `### Added`, `### Changed`, or `### Fixed`.
|
|
||||||
|
|
||||||
### 🚀 Release Flow
|
|
||||||
When changes are ready for deployment:
|
|
||||||
|
|
||||||
1. **Ask user if deploy cycle is desired **
|
|
||||||
|
|
||||||
2. **Update version** in `pyproject.toml`:
|
|
||||||
- Bug fixes: bump patch version (1.8.3 → 1.8.4)
|
|
||||||
- New features: bump minor version (1.8.4 → 1.9.0)
|
|
||||||
|
|
||||||
3. **Update CHANGELOG.md**:
|
|
||||||
- Move items from `[Unreleased]` to new version section
|
|
||||||
- Add release date: `## [1.8.4] - 2025-12-16`
|
|
||||||
|
|
||||||
4. **Commit and tag**:
|
|
||||||
```bash
|
|
||||||
git add -A
|
|
||||||
git commit -m "fix: description of changes"
|
|
||||||
git tag v1.8.4
|
|
||||||
git push origin main --tags
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **CI/CD triggers automatically**:
|
|
||||||
- Gitea CI builds Docker image on new tag
|
|
||||||
- Watchtower pulls and deploys to production
|
|
||||||
- Verify deployment: `curl http://192.168.86.149:8000/health`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. FastAPI Architecture & Best Practices
|
|
||||||
*Reference: [FastAPI Best Practices](https://github.com/zhanymkanov/fastapi-best-practices)*
|
|
||||||
|
|
||||||
### 📂 Project Structure (Directory-based, NOT File-type based)
|
|
||||||
Do **not** group files by type (e.g., one huge `routers` folder). Group by **domain/module** inside a `src/` directory.
|
|
||||||
|
|
||||||
**Correct Structure:**
|
|
||||||
```text
|
|
||||||
src/
|
|
||||||
├── auth/
|
|
||||||
│ ├── router.py # Endpoints
|
|
||||||
│ ├── schemas.py # Pydantic models
|
|
||||||
│ ├── service.py # Business logic (CRUD, etc.)
|
|
||||||
│ ├── dependencies.py# Module-specific dependencies
|
|
||||||
│ └── config.py # Module-specific settings
|
|
||||||
├── posts/
|
|
||||||
│ ├── router.py
|
|
||||||
│ └── ...
|
|
||||||
└── main.py # App entry point
|
|
||||||
+401
@@ -5,6 +5,407 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.12.0] - 2026-10-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **The tenant registry** (`src/domains/tenants/`, core-api T-8 / workspace
|
||||||
|
D-73) - `tenants` and `tenant_consumers` (migration 007), and
|
||||||
|
`TenantsService.require_tenant`, the one enforcement point Book's and
|
||||||
|
Scribe's own `_require_tenant` now delegate to instead of checking only
|
||||||
|
presence: a tenant that is not in the table is 403 `tenant_unknown`, one
|
||||||
|
marked `suspended` is 403 `tenant_suspended`, and an Office caller whose
|
||||||
|
consumer holds no grant in that tenant is 403 `consumer_not_granted` -
|
||||||
|
closing the gap where a typo'd `tatlock_user` claim opened a fresh, empty
|
||||||
|
namespace rather than being refused. Every existing `owner_tenant` column
|
||||||
|
(`book_records`, `book_tenant_counters`, `scribe_addresses`,
|
||||||
|
`scribe_messages`, `scribe_mailbox_entries`) now carries a foreign key to
|
||||||
|
`tenants.id` (`ON DELETE RESTRICT`). Migration 007 seeds the two tenants
|
||||||
|
in use today - `jpmschweitzer` (household) and `llm_tester` (test) - both
|
||||||
|
granted to `office-claude`, the only Office consumer configured in
|
||||||
|
Authentik. Read-only this round: `GET /tenants/me` answers the caller's
|
||||||
|
own tenant and its granted consumers (the cheap "am I allowed here"
|
||||||
|
probe); `GET /tenants` answers every tenant for a `tatlock`/`scheduler`
|
||||||
|
caller or a list of one for a tenant-bearing caller, never revealing
|
||||||
|
another tenant's existence. No write routes - provisioning a tenant is
|
||||||
|
later (workspace T-195).
|
||||||
|
- **The Scribe** (`src/domains/scribe/`) - the household's message bus
|
||||||
|
(dossier core-api T-5 / workspace D-66, D-68, D-73): `register`/`list`
|
||||||
|
addresses, `send`, `mailbox`, `history`, `find`, `mark`, `tag`/`untag`, all
|
||||||
|
under `/scribe/*`, behind `require_caller("office", "user")`. An address is
|
||||||
|
a topic owned by a tenant and a consumer; `send`'s `from` is always the
|
||||||
|
caller's own identity, never the request body. Every query filters on the
|
||||||
|
caller's tenant (D-73) - an address or message in another tenant answers
|
||||||
|
exactly like one that does not exist, so no id is ever confirmed across
|
||||||
|
that boundary. Messaging only this round: groups, subscriptions, the
|
||||||
|
notification matrix and delivery are later work (workspace T-176).
|
||||||
|
Migration 006 creates `scribe_addresses`, `scribe_messages` (retained,
|
||||||
|
full-text indexed), `scribe_mailbox_entries`.
|
||||||
|
- **Caller carries consumer and tenant from the Office** (`src/domains/auth/
|
||||||
|
callers.py`, core-api T-4 / workspace D-73) - `Caller` gains `consumer` and
|
||||||
|
`tenant`. For an `office` caller both `X-Office-Consumer` and
|
||||||
|
`X-Office-Tenant` are required on every call, reads included: missing is
|
||||||
|
400 `consumer_required`/`tenant_required`, malformed is 400
|
||||||
|
`consumer_invalid`/`tenant_invalid`. Every other kind ignores both headers
|
||||||
|
outright - `tatlock` and `scheduler` carry no tenant, and a `user` caller's
|
||||||
|
tenant comes only from their own JWT's `tatlock_user` claim, never from a
|
||||||
|
header.
|
||||||
|
- **Caller auth** (`src/domains/auth/callers.py`) - the first thing in this repo
|
||||||
|
that reads a service key. `require_caller()` resolves a static office/tatlock/
|
||||||
|
scheduler key (constant-time compare, checked before any JWT parsing) or falls
|
||||||
|
through to the existing OIDC path. New settings: `office_service_key`,
|
||||||
|
`tatlock_service_key`, `scheduler_service_key`, `book_timezone`.
|
||||||
|
- **The Book** (`src/domains/book/`) - Notes, Tasks, Decisions, Questions and
|
||||||
|
Rejected options (workspace D-68/D-69/D-70). Routes: `add`, `show`, `list`,
|
||||||
|
`find`, `change`, `close`, `link`/`unlink`, `tag`/`untag`, `history`, all under
|
||||||
|
`/book/*`, behind `require_caller("office", "user")`. Migration 005 creates the
|
||||||
|
whole schema: `book_records`, `book_tags`, `book_record_links`,
|
||||||
|
`book_attachments`, `book_events` (append-only, DB trigger-enforced),
|
||||||
|
`book_tenant_counters`.
|
||||||
|
- **The Book is behind the tenant boundary** (core-api T-7 / workspace D-73) -
|
||||||
|
every route now requires a tenant on the caller (400 `tenant_required` for
|
||||||
|
a `user`-kind caller with no `tatlock_user` claim; an `office` caller
|
||||||
|
already has one from every call per T-4) and filters on it: a record in
|
||||||
|
another tenant answers exactly like one that does not exist (404
|
||||||
|
`not_found`), in both directions, and `link`/`add`'s `parent` convenience
|
||||||
|
refuse a target in another tenant the same way. `add` sets `owner_tenant`
|
||||||
|
from the caller and allocates `number` - a short outward id numbered per
|
||||||
|
tenant (each tenant's Book starts at 1, unique on `(owner_tenant,
|
||||||
|
number)`, never reused), allocated via a per-tenant counter row
|
||||||
|
(`book_tenant_counters`) locked `FOR UPDATE` in the same transaction as
|
||||||
|
the insert. Responses include `number`; `list` accepts `number=` to find
|
||||||
|
one. Migration 005 (unreleased - production was still at 004) is amended
|
||||||
|
in place to add `book_records.owner_tenant`/`number` and
|
||||||
|
`book_tenant_counters`, rather than patching them in with a later
|
||||||
|
migration.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`X-Office-Client` is retired in favour of `X-Office-Consumer`** (core-api
|
||||||
|
T-4) - the informational header is no longer read; `book_events.client`
|
||||||
|
(no migration this round) now records the consumer instead.
|
||||||
|
- **Housekeeping reads are locked** - `GET /housekeeping/*` (health, devices,
|
||||||
|
areas, scenes, scripts, automations, history) now require a caller
|
||||||
|
(`office`, `tatlock`, or `user`) instead of being unauthenticated. Writes are
|
||||||
|
unchanged (`get_admin_user`). `api.schweitz.net` carries no forward-auth, so
|
||||||
|
this was an open read of the house's device list once Home Assistant's token
|
||||||
|
is restored (T-1 / D-68).
|
||||||
|
|
||||||
|
## [1.11.0] - 2026-01-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **News Headlines API** - New endpoint for news ticker integration
|
||||||
|
- `GET /tools/news` - Fetch news headlines from user's volatile collection
|
||||||
|
- Returns headlines with title, description, source, and URL
|
||||||
|
- Data sourced from `volatile_{user}` Qdrant collection (news namespace)
|
||||||
|
- Uses `preferred_username` from OIDC, falls back to `default`
|
||||||
|
- News subdomain under tools (`src/domains/tools/news/`)
|
||||||
|
- `NewsHeadline` and `NewsResponse` Pydantic schemas
|
||||||
|
- `NewsService` for parsing news data from Qdrant
|
||||||
|
|
||||||
|
## [1.10.12] - 2026-01-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Forecast data retrieval** - Pass raw_data to service instead of extracting wrong field
|
||||||
|
- Qdrant client was extracting `days` (integer 7) instead of `daily` (list)
|
||||||
|
- Now passes full raw_data for service to parse correctly
|
||||||
|
|
||||||
|
## [1.10.11] - 2026-01-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Weather wind direction** - Convert integer degrees to cardinal direction string
|
||||||
|
- Scheduler stores wind_direction as degrees (e.g., 135)
|
||||||
|
- Schema expects string (e.g., "SE")
|
||||||
|
- Added `_degrees_to_cardinal()` conversion
|
||||||
|
|
||||||
|
## [1.10.10] - 2026-01-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Environment data parsing** - Fix parsing of scheduler-generated Qdrant data
|
||||||
|
- Forecast: Check `daily` key first (scheduler stores day count in `days`, list in `daily`)
|
||||||
|
- Sun times: Use `sunrise_iso`/`sunset_iso` fields, handle time-only format fallback
|
||||||
|
- Sun times: Calculate daylight from `daylight_duration_seconds` or `daylight_hours`
|
||||||
|
- Air quality: Support `aqi_us`/`aqi_european` and `nitrogen_dioxide` field names
|
||||||
|
|
||||||
|
## [1.10.9] - 2026-01-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Environment user ID cleanup** - Strip email domain from user identifier
|
||||||
|
- If `preferred_username` is an email, extract just the username part
|
||||||
|
- Ensures Qdrant collection name matches (e.g., `volatile_jpmschweitzer` not `volatile_jpmschweitzer@gmail.com`)
|
||||||
|
|
||||||
|
## [1.10.8] - 2026-01-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **OIDC audience validation** - python-jose requires string audience, not list
|
||||||
|
- Extract and validate audience from unverified claims first
|
||||||
|
- Use token's actual audience for JWT decode (after validating it's allowed)
|
||||||
|
- Fixes "audience must be a string or None" error
|
||||||
|
|
||||||
|
## [1.10.7] - 2026-01-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Multi-issuer OIDC support** - Accept tokens from multiple OAuth providers
|
||||||
|
- Changed `oidc_issuer` (string) to `oidc_issuers` (list)
|
||||||
|
- Each issuer has its own JWKS endpoint, now cached per-issuer
|
||||||
|
- Validates token issuer against allowed list before fetching JWKS
|
||||||
|
- Supports tokens from: `core-api`, `tatlock-ui`, `tatlock` OAuth applications
|
||||||
|
- Completes fix for environment endpoint user resolution
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Deprecated `src/config.py` - consolidated to `src/shared/config.py`
|
||||||
|
- Deprecated `src/security.py` - consolidated to `src/shared/security.py`
|
||||||
|
|
||||||
|
## [1.10.6] - 2026-01-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **OIDC audience mismatch** - Accept tokens from multiple clients
|
||||||
|
- Changed `oidc_audience` (string) to `oidc_audiences` (list)
|
||||||
|
- Now accepts tokens with audience: `core-api`, `tatlock-ui`, or `tatlock`
|
||||||
|
- Fixes environment endpoint returning "default" user instead of authenticated username
|
||||||
|
- Fixed main.py to use `oidc_audiences[0]` for Swagger UI OAuth client
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Documentation cleanup in README
|
||||||
|
|
||||||
|
## [1.10.4] - 2026-01-07
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **Ollama integration removed** - AI inference is no longer handled by this API
|
||||||
|
- Removed `src/models/ollama_client.py` and all Ollama-related configuration
|
||||||
|
- Removed `src/models/embeddings.py` and `src/models/embeddings_ollama.py`
|
||||||
|
- Removed model aliases and AI configuration from settings
|
||||||
|
- Health endpoints no longer check Ollama status
|
||||||
|
- Tests updated to reflect database-only health checks
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Health check `/health/full` now only checks database connectivity
|
||||||
|
- Diagnostics endpoint simplified (removed Ollama component info)
|
||||||
|
|
||||||
|
## [1.10.3] - 2026-01-07
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **OIDC config not applied to domains module** - Both `src.auth.oidc` and `src.domains.auth.oidc` configs are now initialized
|
||||||
|
- Previously only `src.auth.oidc` was configured, leaving domains tools using hardcoded "local" user
|
||||||
|
- Environment endpoint now correctly uses authenticated user from OIDC token
|
||||||
|
|
||||||
|
## [1.10.2] - 2026-01-07
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Enhanced environment endpoint logging** - Added detailed user claim logging for debugging
|
||||||
|
- Logs both `preferred_username` and `sub` claims when resolving user
|
||||||
|
- Distinguishes between authenticated and unauthenticated requests
|
||||||
|
|
||||||
|
## [1.10.1] - 2026-01-06
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix OIDC import path in tools controller (`src.oidc.dependencies` → `src.auth.oidc`)
|
||||||
|
- Environment endpoint was returning `user: "local"` instead of authenticated username
|
||||||
|
- Caused queries to wrong Qdrant collection (`volatile_local` vs `volatile_{username}`)
|
||||||
|
|
||||||
|
## [1.10.0] - 2026-01-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Environment Data API** - Qdrant-backed endpoint for weather, forecast, and sun position data
|
||||||
|
- `GET /tools/environment` - Fetch environment data from user's volatile collection
|
||||||
|
- Weather: current temperature, conditions, humidity, wind speed
|
||||||
|
- Forecast: multi-day outlook with high/low temperatures
|
||||||
|
- Sun times: sunrise, sunset, daylight duration
|
||||||
|
- Air quality: AQI and quality level (when available)
|
||||||
|
- Data sourced from `volatile_{user}` Qdrant collection
|
||||||
|
- Uses `preferred_username` from OIDC, falls back to `default`
|
||||||
|
- `qdrant-client` dependency for vector database access
|
||||||
|
- `QdrantReadClient` wrapper for read-only collection queries
|
||||||
|
- Comprehensive test suite for environment service parsing
|
||||||
|
|
||||||
|
## [1.9.4] - 2026-01-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix SQLAlchemy async lazy loading error for new users in `/auth/sync`
|
||||||
|
- Initialize `user.roles = []` and `user.preferences` to avoid greenlet error
|
||||||
|
- Was causing "MissingGreenlet: greenlet_spawn has not been called" on new user creation
|
||||||
|
|
||||||
|
## [1.9.3] - 2026-01-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Handle non-UUID `sub` claim in `/auth/sync` - Authentik JWT may return non-UUID subject identifiers
|
||||||
|
- Now derives deterministic UUID from sub string if direct parsing fails
|
||||||
|
|
||||||
|
## [1.9.2] - 2026-01-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `/auth/users/me` endpoint now supports both NPM forward auth headers AND JWT Bearer tokens
|
||||||
|
- Added `get_current_user_or_forward_auth()` combined auth dependency
|
||||||
|
- Fixes web authentication where NPM passes `X-authentik-*` headers instead of JWT
|
||||||
|
- Mobile/native clients continue to use JWT Bearer tokens as before
|
||||||
|
|
||||||
|
## [1.9.1] - 2026-01-03
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- CORS configuration now uses explicit origins instead of `"*"`
|
||||||
|
- When `allow_credentials=True`, wildcard origins are rejected by browsers
|
||||||
|
- Added `home.schweitz.net`, `tatlock.schweitz.net`, and localhost origins
|
||||||
|
|
||||||
|
## [1.9.0] - 2026-01-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **NPM Forward Auth Support** - Web authentication via Nginx Proxy Manager forward auth
|
||||||
|
- `GET /auth/me` - Get current user from NPM forward auth headers (X-authentik-uid, X-authentik-email, etc.)
|
||||||
|
- Auto-creates user on first web login if not in database
|
||||||
|
- Syncs roles from NPM forward auth groups header
|
||||||
|
- `get_user_by_email` and `get_user_by_authentik_id` methods in AuthService
|
||||||
|
- Comprehensive tests for `/auth/me` endpoint
|
||||||
|
|
||||||
|
## [1.8.0] - 2026-01-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Group-Role Mapping & Permissions** (Phase 3)
|
||||||
|
- Decoupled group-role architecture (groups from Authentik, roles admin-managed)
|
||||||
|
- Permission format: `domain.category:action` with action hierarchy
|
||||||
|
- `require_permission` and `require_any_permission` dependency factories
|
||||||
|
- Global admin override (`admin.general:admin`)
|
||||||
|
- **User Profile & API Keys** (Phase 4)
|
||||||
|
- `GET /auth/users/me` - Full user profile with roles and preferences
|
||||||
|
- `GET/PATCH /auth/users/me/preferences` - User preferences management
|
||||||
|
- `GET/POST/DELETE /auth/users/me/api-keys` - API key lifecycle
|
||||||
|
- API keys with `tak_` prefix, SHA-256 hashing, shown only once on creation
|
||||||
|
|
||||||
|
## [1.7.0] - 2026-01-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **System Stats API** - Host system resource monitoring for dashboard widgets
|
||||||
|
- `GET /tools/system/stats` - Real-time host system statistics
|
||||||
|
- CPU: usage percentage, core count, load averages
|
||||||
|
- Memory: usage percentage, total/used/available bytes
|
||||||
|
- Disks: all mounted filesystems with usage stats (auto-discovers mounts)
|
||||||
|
- Network: total bytes sent/received
|
||||||
|
- GPU/VRAM: NVIDIA GPU memory usage (via nvidia-smi if available)
|
||||||
|
- `psutil` dependency for cross-platform system metrics
|
||||||
|
|
||||||
|
## [1.6.1] - 2026-01-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `link_type` field to quick links for iframe vs new tab behavior
|
||||||
|
|
||||||
|
## [1.6.0] - 2026-01-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Dashboard API** - Quick links and widgets management for Organizr-style dashboard
|
||||||
|
- `GET /dashboard/quick-links` - List quick links with category/visibility filtering
|
||||||
|
- `GET /dashboard/quick-links/{id}` - Get single quick link
|
||||||
|
- `POST /dashboard/quick-links` - Create quick link
|
||||||
|
- `PUT /dashboard/quick-links/{id}` - Update quick link
|
||||||
|
- `DELETE /dashboard/quick-links/{id}` - Delete quick link
|
||||||
|
- `POST /dashboard/quick-links/reorder` - Reorder quick links by position
|
||||||
|
- `GET /dashboard/widgets` - List dashboard widgets
|
||||||
|
- `GET /dashboard/widgets/{id}` - Get single widget
|
||||||
|
- `POST /dashboard/widgets` - Create widget
|
||||||
|
- `PUT /dashboard/widgets/{id}` - Update widget
|
||||||
|
- `DELETE /dashboard/widgets/{id}` - Delete widget
|
||||||
|
- Database migrations for `quick_links` and `dashboard_widgets` tables
|
||||||
|
- Static file controller for serving Organizr widgets (`/static/widgets`)
|
||||||
|
- Default local user authentication when OIDC is disabled
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Domain-based architecture** - Refactored codebase to domain-driven structure
|
||||||
|
- `src/domains/` - Domain modules (auth, dashboard, health, housekeeping, infrastructure, tools)
|
||||||
|
- `src/shared/` - Shared utilities (base, config, database, logging, security, clients)
|
||||||
|
- Test suite updated for new domain structure (285 tests passing)
|
||||||
|
|
||||||
|
## [1.5.0] - 2026-01-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Groups Management** - Authentik group synchronization
|
||||||
|
- `GET /auth/groups` - List all groups with search and pagination
|
||||||
|
- `POST /auth/groups/sync-from-authentik` - Bulk sync groups from Authentik admin API
|
||||||
|
- Database model and migration for groups table
|
||||||
|
|
||||||
|
## [1.4.6] - 2026-01-01
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Code cleanup: move inline `re` import to top of auth/service.py
|
||||||
|
|
||||||
|
## [1.4.5] - 2026-01-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Separate httpx and SQLAlchemy async contexts in bulk sync (fixes greenlet error)
|
||||||
|
|
||||||
|
## [1.4.4] - 2026-01-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Use `uuid` field instead of `pk` for Authentik user sync (pk is integer, uuid is proper UUID)
|
||||||
|
- Skip internal_service_account type users during bulk sync
|
||||||
|
|
||||||
|
## [1.4.3] - 2026-01-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Manually extract and send session cookies for Authentik flow auth (fixes cross-domain cookie handling)
|
||||||
|
|
||||||
|
## [1.4.2] - 2026-01-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Use Authentik domain URL (auth.schweitz.net) instead of IP to fix cookie domain matching
|
||||||
|
|
||||||
|
## [1.4.1] - 2026-01-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Authentik config now uses AUTHENTIK_USERNAME/PASSWORD to match production env vars
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-01-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Authentication & User Management** - Authentik integration for user synchronization
|
||||||
|
- `GET /auth/me` - Get current authenticated user info
|
||||||
|
- `GET /auth/users` - List all users with search and pagination
|
||||||
|
- `POST /auth/users/sync-from-authentik` - Bulk sync users from Authentik admin API
|
||||||
|
- PostgreSQL database integration with async SQLAlchemy
|
||||||
|
- Alembic database migrations for schema management
|
||||||
|
- Database models: User, Role, UserPreferences, ApiKey
|
||||||
|
- Token validation via Authentik userinfo endpoint
|
||||||
|
- Role synchronization from Authentik groups
|
||||||
|
- Health check now includes database connectivity status
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Authentik configuration now uses username/password for admin API access
|
||||||
|
- Health endpoint includes database status in diagnostics
|
||||||
|
|
||||||
## [1.3.1] - 2025-12-31
|
## [1.3.1] - 2025-12-31
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# CLAUDE.md — core-api
|
||||||
|
|
||||||
|
FastAPI service providing infrastructure management, home automation, and utility
|
||||||
|
endpoints for the homelab. Talks to Portainer, Nginx Proxy Manager, Home Assistant,
|
||||||
|
Postgres (via SQLAlchemy async + Alembic), Qdrant, and Authentik (OIDC). Deployed on
|
||||||
|
tower-of-joy at **:8083**.
|
||||||
|
|
||||||
|
## Ports — these differ, deliberately
|
||||||
|
|
||||||
|
| | Port | How |
|
||||||
|
|---|---|---|
|
||||||
|
| Local dev | **8788** | `./wakeup.sh`, uvicorn `--reload`, logs to `logs/server.log` |
|
||||||
|
| Production | **8083** | container; health at `http://192.168.86.149:8083/health` |
|
||||||
|
|
||||||
|
Testing `localhost:8083` on the dev box hits the *container*, not your reload server.
|
||||||
|
|
||||||
|
## Live contract
|
||||||
|
|
||||||
|
The live contract is always `http://localhost:8083/openapi.json` (62 paths, verified
|
||||||
|
2026-08-09) and human docs at `http://localhost:8083/docs` / `/redoc` — generated from
|
||||||
|
running code, so query it rather than inferring routes from source or from the README's
|
||||||
|
endpoint list, which can drift.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Domain-first layout under `src/domains/<name>/{controller,models,schemas,service}.py`
|
||||||
|
(auth, dashboard, health, housekeeping, infrastructure, static, tools). `src/main.py`
|
||||||
|
wires only `src.domains.*` — verify by reading its imports.
|
||||||
|
|
||||||
|
**Legacy top-level packages — "not in `main.py`" does not mean dead.** Routes are wired
|
||||||
|
only from `src.domains.*`, so grepping `main.py`'s imports looks like it settles which
|
||||||
|
packages are live. It does not. `main.py:55` calls `initialize_oidc()`, and that function
|
||||||
|
(`src/shared/security.py:21`) deliberately imports and configures **both** `src.auth.oidc`
|
||||||
|
and `src.domains.auth.oidc` — a function-body import, invisible to a grep of `main.py`.
|
||||||
|
That one call drags in `src/auth/`, `src/controllers/`, `src/db/`, `src/logging_config.py`
|
||||||
|
and `src/base_schema.py` at startup.
|
||||||
|
|
||||||
|
Three tiers, established by importing the app inside the container and reading
|
||||||
|
`sys.modules` (verified 2026-08-09):
|
||||||
|
|
||||||
|
| Tier | Packages |
|
||||||
|
|---|---|
|
||||||
|
| Serving routes | `src/domains/`, `src/shared/`, `src/service_groups/` |
|
||||||
|
| **Loaded and configured**, but serving no routes | `src/auth/`, `src/db/`, `src/controllers/`, `src/logging_config.py`, `src/base_schema.py` |
|
||||||
|
| Genuinely unreferenced | `src/agent/`, `src/api/`, `src/clients/`, `src/dns/`, `src/memory/`, `src/models/` |
|
||||||
|
|
||||||
|
`src/auth/` is the trap. Its `oidc_config` singleton is configured at every startup with
|
||||||
|
the real Authentik issuers — the log line `src.auth.oidc:configure` proves it — so a test
|
||||||
|
importing `src.auth.oidc` is exercising live, configured code, not a fossil. No `src/domains/*`
|
||||||
|
module depends on it, so it is configured defensively rather than used; that makes it a
|
||||||
|
deletion candidate, but a considered one, not obvious cleanup.
|
||||||
|
|
||||||
|
**Before deleting anything from `src/`, import the app and read `sys.modules`** rather than
|
||||||
|
grepping `main.py`. Function-body imports exist here specifically to dodge circular imports,
|
||||||
|
and they are exactly what a grep misses.
|
||||||
|
|
||||||
|
**That check has its own blind spot, so do not read the third tier as a delete list.** The
|
||||||
|
table above is a snapshot taken after a cold `import src.main` — it shows what *startup*
|
||||||
|
loads. A module imported inside a request handler would be absent from it while being
|
||||||
|
entirely live, and absence would then be a timing artifact rather than evidence of death.
|
||||||
|
This bit on webber, where a tool package imported from inside an agent method looked
|
||||||
|
unloaded and was serving every request. Nothing in core-api is currently known to work that
|
||||||
|
way, but that is the weaker claim — it means nobody has exercised the routes and re-checked,
|
||||||
|
not that nobody does it. Before deleting a third-tier package, drive the endpoints that
|
||||||
|
would plausibly load it and take the snapshot again.
|
||||||
|
|
||||||
|
Some tests (`test_auth_controller.py`, `test_oidc.py`, `test_npm_client.py`,
|
||||||
|
`test_portainer_client.py`, `test_static_controller.py`, `test_tools_controller.py`) import
|
||||||
|
from the top-level paths rather than `src.domains.*`. Which of those cover live code follows
|
||||||
|
the table above — `test_oidc.py` does; the client tests target the unreferenced tier. Not
|
||||||
|
cleaned up in this pass; flagged, not fixed.
|
||||||
|
|
||||||
|
Shared infra (config, database, logging, security/OIDC, external API clients) lives in
|
||||||
|
`src/shared/`.
|
||||||
|
|
||||||
|
Group new work by **domain, not by file type** — a single large `routers/` folder is
|
||||||
|
the thing to avoid. Reference: [FastAPI best practices](https://github.com/zhanymkanov/fastapi-best-practices).
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
SQLAlchemy 2.0 async + asyncpg, migrations via Alembic (`alembic/versions/`). Models
|
||||||
|
live under `src/domains/<name>/models.py` and must be imported in `alembic/env.py` to
|
||||||
|
register with `Base.metadata` — check that file when adding a new model or `alembic
|
||||||
|
revision --autogenerate` will silently miss it.
|
||||||
|
|
||||||
|
## Working here
|
||||||
|
|
||||||
|
**Plan, act, reflect.** Outline which files you will touch and the side effects before
|
||||||
|
writing. Change in small atomic steps. Afterwards, verify: did existing tests break, and
|
||||||
|
does the new behaviour have a test?
|
||||||
|
|
||||||
|
**Test locally first — the build-deploy loop is slow.** `./wakeup.sh` auto-reloads on
|
||||||
|
code changes (not on `requirements.txt` changes; restart the container/script after
|
||||||
|
adding a dependency). Deploy only when a feature is complete and tested.
|
||||||
|
|
||||||
|
Run tests through the venv explicitly, to avoid environment mismatch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m pytest tests/
|
||||||
|
# or, with coverage:
|
||||||
|
.venv/bin/python -m pytest --cov=src --cov-report=term-missing
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and configure Portainer, NPM, Home Assistant, SearXNG,
|
||||||
|
Postgres, and Qdrant hosts/credentials.
|
||||||
|
|
||||||
|
No linter is configured in this repo (no ruff/flake8 config, none in `requirements.txt`
|
||||||
|
or `dev-requirements.txt`) — unlike some sibling repos, don't assume `ruff check` exists
|
||||||
|
here.
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
`.gitea/workflows/build.yml` is the only workflow: triggered on `v*` tag push, it
|
||||||
|
creates a Gitea release, builds and pushes the image, then pings Watchtower. There is
|
||||||
|
**no CI test/lint gate** — pytest only runs locally or on request. Verify tests pass
|
||||||
|
before tagging a release.
|
||||||
|
|
||||||
|
## Work tracking
|
||||||
|
|
||||||
|
Work lives in **pql**, not a markdown TODO. **This repo's vault is standalone** — its tickets
|
||||||
|
and its internal decisions live here in `.pql/` and `governance/`, and travel with a clone,
|
||||||
|
because `.pql/changelog/` is committed and replayed by the git hooks (workspace D-15). The databases are
|
||||||
|
gitignored and rebuildable with `pql plan rebuild`.
|
||||||
|
|
||||||
|
`pql` is **not** on the non-interactive `PATH` — invoke it as
|
||||||
|
`/home/jpmschweitzer/.local/bin/pql`. From inside this repo no `--vault` is needed: pql anchors
|
||||||
|
at the nearest `.git/` ancestor, which is this repo.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/home/jpmschweitzer/.local/bin/pql ticket list # this repo's open work
|
||||||
|
/home/jpmschweitzer/.local/bin/pql plan whatsnext # next unblocked item, with context
|
||||||
|
/home/jpmschweitzer/.local/bin/pql decisions list # this repo's own decisions
|
||||||
|
```
|
||||||
|
|
||||||
|
Stack-level decisions that constrain this service live in the **workspace** vault and need the
|
||||||
|
flag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/home/jpmschweitzer/.local/bin/pql --vault /mnt/media/Projects decisions list --domain core-api
|
||||||
|
```
|
||||||
|
|
||||||
|
Note `ticket new --decision D-N` resolves ids within **one** vault, so a ticket here cannot link
|
||||||
|
to a workspace decision. Cite the id in the ticket body instead.
|
||||||
|
|
||||||
|
Do not add a TODO section to a markdown file.
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
- **History is linear — no merge commits.** Work on `main`, or a short-lived branch
|
||||||
|
that is fast-forwarded and deleted. (This repo's AGENTS.md previously mandated a
|
||||||
|
feature branch for every change; that rule was retired workspace-wide on 2026-08-08
|
||||||
|
and does not apply here anymore.)
|
||||||
|
- **Conventional Commits**: `feat:`, `fix:`, `refactor:`, `docs:`, `chore:`.
|
||||||
|
- **Atomic commits** — one logical change each.
|
||||||
|
- **Stage explicitly. Never `git add -A`** — it is denied by policy, and it sweeps in
|
||||||
|
whatever else is dirty, including secrets.
|
||||||
|
- Update `CHANGELOG.md` with every user-facing change, under `[Unreleased]` in `Added` /
|
||||||
|
`Changed` / `Fixed`.
|
||||||
|
|
||||||
|
## Releasing
|
||||||
|
|
||||||
|
Ask whether a deploy is wanted first — it is not automatic.
|
||||||
|
|
||||||
|
1. Bump the version in `pyproject.toml` (patch for fixes, minor for features).
|
||||||
|
2. Move `[Unreleased]` entries into a dated version section in `CHANGELOG.md`.
|
||||||
|
3. Stage the changed files by name, commit, tag `vX.Y.Z`, `git push origin main --tags`.
|
||||||
|
4. Gitea CI (`build.yml`) builds and pushes the image on the tag; Watchtower deploys it.
|
||||||
|
5. Verify: `curl http://192.168.86.149:8083/health`.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- OIDC authentication via Authentik, multi-issuer/multi-audience support.
|
||||||
|
- Admin endpoints require authentication when `OIDC_ENABLED=true`.
|
||||||
|
- README claims the container "runs as non-root user (uid 1000)" — **checked and
|
||||||
|
false**: the Dockerfile has no `USER` directive, so the container runs as root.
|
||||||
|
Not fixed here (out of scope for a docs normalization pass); flagging so it isn't
|
||||||
|
restated as fact.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# core-api — the repo's command surface (D-27).
|
||||||
|
#
|
||||||
|
# Paths resolve here rather than in callers (D-10). `python3` on this host is
|
||||||
|
# 3.8 and cannot parse these sources, and a bare `pytest` resolves only in a
|
||||||
|
# login shell — so both are named explicitly through the venv.
|
||||||
|
|
||||||
|
VENV := $(CURDIR)/.venv
|
||||||
|
PYTHON ?= python3.12
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
.PHONY: help
|
||||||
|
help: ## Show this help
|
||||||
|
@grep -hE '^[a-z][a-z0-9_-]*:.*?## ' $(MAKEFILE_LIST) \
|
||||||
|
| awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
.PHONY: setup
|
||||||
|
setup: ## Create the venv, install dependencies, and prove the result is usable
|
||||||
|
$(PYTHON) -m venv .venv
|
||||||
|
$(VENV)/bin/pip install -r requirements.txt -r dev-requirements.txt
|
||||||
|
# Exit 0 from pip install is not evidence (D-24) — it is the step's job not
|
||||||
|
# to fail, so a broken result and a working one look identical from here.
|
||||||
|
# On 2026-08-09 the venv existed and pip had exited 0, but sqlalchemy was
|
||||||
|
# declared in requirements.txt and not installed; that surfaced as 11
|
||||||
|
# pytest collection errors that read as broken imports, not as a setup
|
||||||
|
# problem. `--collect-only` exercises every import the suite touches
|
||||||
|
# without running a single test, so it catches exactly that class of
|
||||||
|
# drift and stays cheap. `pip check` was considered too, but it only
|
||||||
|
# verifies the *installed* set's internal consistency against itself —
|
||||||
|
# it would not have caught this case, because sqlalchemy was still
|
||||||
|
# present as another package's transitive dependency even when dropped
|
||||||
|
# from requirements.txt. collect-only checks declared-vs-actually-usable
|
||||||
|
# directly, which is the axis that broke.
|
||||||
|
$(VENV)/bin/python -m pytest --collect-only tests/
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test: ## Run the test suite
|
||||||
|
@test -x $(VENV)/bin/python || { echo "FAIL — no venv; run: make setup"; exit 69; }
|
||||||
|
$(VENV)/bin/python -m pytest tests/
|
||||||
|
|
||||||
|
# No `lint` target, deliberately. This repo configures no linter — no ruff or
|
||||||
|
# flake8 config, and neither in requirements. Per D-27 the name is reserved for
|
||||||
|
# repos that lint; an empty target here would report clean for something never
|
||||||
|
# run. Add the target when a linter is added, not before.
|
||||||
|
|
||||||
|
# git hands a hook a non-login shell, which never sees ~/.local/bin — where
|
||||||
|
# gitleaks lands. Without this the scan reports "not installed" on every push,
|
||||||
|
# which is a check that fails open (D-24).
|
||||||
|
export PATH := $(HOME)/.local/bin:/usr/local/bin:$(PATH)
|
||||||
|
|
||||||
|
.PHONY: secrets
|
||||||
|
secrets: ## Scan the commits about to be pushed for credentials
|
||||||
|
@ci/secrets.sh
|
||||||
|
|
||||||
|
# The call surface is identical in every repo; what it runs is not.
|
||||||
|
#
|
||||||
|
# `secrets` runs first, deliberately: it is the only failure here that cannot be
|
||||||
|
# undone by fixing it afterwards. A failed lint costs another commit; a pushed
|
||||||
|
# credential is cached and indexed whether or not it is later deleted.
|
||||||
|
#
|
||||||
|
# Some of these fail today, and are left wired anyway. The state was measured
|
||||||
|
# once and written down in T-56 rather than being worked around here — a gate
|
||||||
|
# quietly narrowed to what already passes is a gate that reports success for
|
||||||
|
# doing nothing, which is the failure this workspace keeps rediscovering.
|
||||||
|
.PHONY: pre-push
|
||||||
|
pre-push: secrets ## Everything the pre-push hook runs
|
||||||
|
@echo " -- not gated here yet: lint (no linter configured) and test (T-56)"
|
||||||
@@ -19,8 +19,7 @@ Central API service providing infrastructure management, home automation, and ut
|
|||||||
|
|
||||||
### Utilities
|
### Utilities
|
||||||
- **DNS Lookup**: Query DNS records (A, AAAA, MX, TXT, CNAME, NS, SOA, PTR)
|
- **DNS Lookup**: Query DNS records (A, AAAA, MX, TXT, CNAME, NS, SOA, PTR)
|
||||||
- **Health Checks**: Comprehensive service health monitoring
|
- **Health Checks**: Service health monitoring with database connectivity status
|
||||||
- **AI Metrics Proxy**: Forward metrics requests to Core-AI service
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -35,10 +34,8 @@ src/
|
|||||||
├── clients/
|
├── clients/
|
||||||
│ ├── homeassistant_client.py # Home Assistant REST client
|
│ ├── homeassistant_client.py # Home Assistant REST client
|
||||||
│ ├── npm_client.py # Nginx Proxy Manager client
|
│ ├── npm_client.py # Nginx Proxy Manager client
|
||||||
│ ├── ollama_client.py # Ollama LLM client
|
|
||||||
│ └── portainer_client.py # Portainer API client
|
│ └── portainer_client.py # Portainer API client
|
||||||
├── controllers/
|
├── controllers/
|
||||||
│ ├── ai_controller.py # AI metrics proxy
|
|
||||||
│ ├── health_controller.py # Health endpoints
|
│ ├── health_controller.py # Health endpoints
|
||||||
│ ├── housekeeping_controller.py # Home automation endpoints
|
│ ├── housekeeping_controller.py # Home automation endpoints
|
||||||
│ ├── infrastructure_controller.py # Infrastructure management
|
│ ├── infrastructure_controller.py # Infrastructure management
|
||||||
@@ -88,9 +85,6 @@ src/
|
|||||||
### Tools (`/tools`)
|
### Tools (`/tools`)
|
||||||
- `POST /tools/dns/lookup` - DNS record lookup
|
- `POST /tools/dns/lookup` - DNS record lookup
|
||||||
|
|
||||||
### AI (`/ai`)
|
|
||||||
- `GET /ai/metrics` - Proxy to Core-AI metrics
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
@@ -103,10 +97,6 @@ src/
|
|||||||
# Install dependencies
|
# Install dependencies
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
# Copy credentials template
|
|
||||||
cp src/credentials.example.py src/credentials.py
|
|
||||||
# Edit src/credentials.py with your values
|
|
||||||
|
|
||||||
# Run locally
|
# Run locally
|
||||||
uvicorn src.main:app --reload --host 0.0.0.0 --port 8083
|
uvicorn src.main:app --reload --host 0.0.0.0 --port 8083
|
||||||
```
|
```
|
||||||
@@ -169,10 +159,9 @@ docker run -p 8083:8083 core-code:latest
|
|||||||
| `NPM_PASSWORD` | NPM admin password | - |
|
| `NPM_PASSWORD` | NPM admin password | - |
|
||||||
| `HOMEASSISTANT_URL` | Home Assistant URL | `http://localhost:8123` |
|
| `HOMEASSISTANT_URL` | Home Assistant URL | `http://localhost:8123` |
|
||||||
| `HOMEASSISTANT_TOKEN` | HA long-lived access token | - |
|
| `HOMEASSISTANT_TOKEN` | HA long-lived access token | - |
|
||||||
| `OLLAMA_URL` | Ollama API URL | `http://localhost:11434` |
|
|
||||||
| `OIDC_ENABLED` | Enable OIDC auth | `false` |
|
| `OIDC_ENABLED` | Enable OIDC auth | `false` |
|
||||||
| `OIDC_ISSUER` | OIDC issuer URL | - |
|
| `OIDC_ISSUERS` | OIDC issuer URLs (comma-separated) | See config.py |
|
||||||
| `OIDC_AUDIENCE` | OIDC audience | - |
|
| `OIDC_AUDIENCES` | OIDC audiences (comma-separated) | See config.py |
|
||||||
|
|
||||||
## API Documentation
|
## API Documentation
|
||||||
|
|
||||||
@@ -183,9 +172,9 @@ Once deployed, access documentation at:
|
|||||||
|
|
||||||
## Health Checks
|
## Health Checks
|
||||||
|
|
||||||
- **Basic**: `GET /health` - Returns status and Ollama connection
|
- **Basic**: `GET /health` - Fast liveness check for container orchestration
|
||||||
- **Full**: `GET /health/full` - Returns all component statuses (503 if unhealthy)
|
- **Full**: `GET /health/full` - Returns database status (503 if unhealthy)
|
||||||
- **Diagnostics**: `GET /health/diagnostics` - Detailed service information
|
- **Diagnostics**: `GET /health/diagnostics` - Service info and configuration
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
|
|||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
# Alembic Configuration for Core-API
|
||||||
|
#
|
||||||
|
# Database migrations using async SQLAlchemy
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# Path to migration scripts
|
||||||
|
script_location = alembic
|
||||||
|
|
||||||
|
# Template for migration file names
|
||||||
|
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# Prepend sys.path with the project root
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
# Timezone for revision creation date
|
||||||
|
timezone = UTC
|
||||||
|
|
||||||
|
# Max length of revision identifiers
|
||||||
|
truncate_slug_length = 40
|
||||||
|
|
||||||
|
# Set to 'true' to run in offline mode
|
||||||
|
revision_environment = false
|
||||||
|
|
||||||
|
# Set to 'true' for sqlalchemy.url to be from the environment
|
||||||
|
# We use env.py to get the URL from config.py instead
|
||||||
|
sqlalchemy.url =
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# Black formatting on generated migration files
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 100 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
Alembic Migrations for Core-API
|
||||||
|
|
||||||
|
This directory contains database migrations managed by Alembic.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
# Generate a new migration (after changing models)
|
||||||
|
alembic revision --autogenerate -m "description"
|
||||||
|
|
||||||
|
# Apply all pending migrations
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
# Rollback last migration
|
||||||
|
alembic downgrade -1
|
||||||
|
|
||||||
|
# View migration history
|
||||||
|
alembic history
|
||||||
|
|
||||||
|
# View current revision
|
||||||
|
alembic current
|
||||||
|
|
||||||
|
See https://alembic.sqlalchemy.org for more documentation.
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
"""
|
||||||
|
Alembic Environment Configuration
|
||||||
|
|
||||||
|
Async migration environment for SQLAlchemy 2.0 with asyncpg.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# Import our models and config
|
||||||
|
from src.shared.config import get_settings
|
||||||
|
from src.shared.database import Base
|
||||||
|
|
||||||
|
# Import all models to ensure they're registered with Base.metadata
|
||||||
|
from src.domains.auth.models import User, Role, UserRole, UserPreferences, ApiKey # noqa: F401
|
||||||
|
from src.domains.dashboard.models import QuickLink, DashboardWidget # noqa: F401
|
||||||
|
from src.domains.book.models import ( # noqa: F401
|
||||||
|
BookRecord, BookTag, BookRecordLink, BookAttachment, BookEvent, BookTenantCounter,
|
||||||
|
)
|
||||||
|
from src.domains.scribe.models import ( # noqa: F401
|
||||||
|
ScribeAddress, ScribeMessage, ScribeMailboxEntry,
|
||||||
|
)
|
||||||
|
from src.domains.tenants.models import Tenant, TenantConsumer # noqa: F401
|
||||||
|
|
||||||
|
# Alembic Config object
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# Model metadata for autogenerate support
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
# Get database URL from our settings
|
||||||
|
settings = get_settings()
|
||||||
|
db_url = settings.database_url
|
||||||
|
if db_url.startswith("postgresql://"):
|
||||||
|
db_url = db_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""
|
||||||
|
Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
Generates SQL script without connecting to the database.
|
||||||
|
"""
|
||||||
|
context.configure(
|
||||||
|
url=db_url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
|
"""
|
||||||
|
Run migrations with the given connection.
|
||||||
|
"""
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async_migrations() -> None:
|
||||||
|
"""
|
||||||
|
Run migrations in 'online' mode with async engine.
|
||||||
|
"""
|
||||||
|
configuration = config.get_section(config.config_ini_section) or {}
|
||||||
|
configuration["sqlalchemy.url"] = db_url
|
||||||
|
|
||||||
|
connectable = async_engine_from_config(
|
||||||
|
configuration,
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""
|
||||||
|
Run migrations in 'online' mode.
|
||||||
|
"""
|
||||||
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""Create auth tables
|
||||||
|
|
||||||
|
Revision ID: 001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-01-01
|
||||||
|
|
||||||
|
Creates the initial authentication and authorization tables:
|
||||||
|
- users: User accounts synced from Authentik
|
||||||
|
- roles: Domain-scoped permission roles
|
||||||
|
- user_roles: User-Role association table
|
||||||
|
- user_preferences: User settings and preferences
|
||||||
|
- api_keys: API key authentication
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "001"
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Users table
|
||||||
|
op.create_table(
|
||||||
|
"users",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("authentik_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("email", sa.String(255), nullable=False),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("avatar_url", sa.String(500), nullable=True),
|
||||||
|
sa.Column("api_keys_enabled", sa.Boolean(), nullable=False, server_default="true"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("last_login", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_users_authentik_id", "users", ["authentik_id"], unique=True)
|
||||||
|
op.create_index("ix_users_email", "users", ["email"], unique=True)
|
||||||
|
|
||||||
|
# Roles table
|
||||||
|
op.create_table(
|
||||||
|
"roles",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(100), nullable=False, comment="Role name in format domain:action"),
|
||||||
|
sa.Column("domain", sa.String(50), nullable=False, comment="Permission domain"),
|
||||||
|
sa.Column("action", sa.String(20), nullable=False, comment="Permission action"),
|
||||||
|
sa.Column("authentik_group", sa.String(255), nullable=True, comment="Corresponding Authentik group name"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_roles_name", "roles", ["name"], unique=True)
|
||||||
|
op.create_index("ix_roles_domain", "roles", ["domain"])
|
||||||
|
op.create_index("ix_roles_authentik_group", "roles", ["authentik_group"], unique=True)
|
||||||
|
|
||||||
|
# User-Role association table
|
||||||
|
op.create_table(
|
||||||
|
"user_roles",
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
sa.Column("role_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# User preferences table
|
||||||
|
op.create_table(
|
||||||
|
"user_preferences",
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
sa.Column("theme", sa.String(20), nullable=False, server_default="system", comment="Theme preference"),
|
||||||
|
sa.Column("default_room", sa.String(50), nullable=False, server_default="front-hall", comment="Default room"),
|
||||||
|
sa.Column("preferences_json", postgresql.JSONB(), nullable=False, server_default="{}"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# API keys table
|
||||||
|
op.create_table(
|
||||||
|
"api_keys",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("name", sa.String(100), nullable=False, comment="Human-readable key name"),
|
||||||
|
sa.Column("key_hash", sa.String(255), nullable=False, comment="SHA-256 hash of the API key"),
|
||||||
|
sa.Column("key_prefix", sa.String(8), nullable=False, comment="First 8 chars for identification"),
|
||||||
|
sa.Column("scopes", postgresql.ARRAY(sa.String()), nullable=True, comment="Optional scope restriction"),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_api_keys_user_id", "api_keys", ["user_id"])
|
||||||
|
|
||||||
|
# Seed initial roles (domain:action combinations)
|
||||||
|
roles_table = sa.table(
|
||||||
|
"roles",
|
||||||
|
sa.column("id", postgresql.UUID),
|
||||||
|
sa.column("name", sa.String),
|
||||||
|
sa.column("domain", sa.String),
|
||||||
|
sa.column("action", sa.String),
|
||||||
|
sa.column("authentik_group", sa.String),
|
||||||
|
)
|
||||||
|
|
||||||
|
domains = [
|
||||||
|
"control-room",
|
||||||
|
"library",
|
||||||
|
"media",
|
||||||
|
"ai",
|
||||||
|
"housekeeper",
|
||||||
|
"developer",
|
||||||
|
"documents",
|
||||||
|
"gaming",
|
||||||
|
"admin",
|
||||||
|
]
|
||||||
|
actions = ["viewer", "user", "editor", "admin"]
|
||||||
|
|
||||||
|
roles_data = []
|
||||||
|
for domain in domains:
|
||||||
|
for action in actions:
|
||||||
|
role_name = f"{domain}:{action}"
|
||||||
|
authentik_group = f"tatlock-{domain}-{action}"
|
||||||
|
roles_data.append({
|
||||||
|
"id": sa.text("gen_random_uuid()"),
|
||||||
|
"name": role_name,
|
||||||
|
"domain": domain,
|
||||||
|
"action": action,
|
||||||
|
"authentik_group": authentik_group,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Insert roles using raw SQL for UUID generation
|
||||||
|
for role in roles_data:
|
||||||
|
op.execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO roles (id, name, domain, action, authentik_group)
|
||||||
|
VALUES (gen_random_uuid(), '{role["name"]}', '{role["domain"]}', '{role["action"]}', '{role["authentik_group"]}')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("api_keys")
|
||||||
|
op.drop_table("user_preferences")
|
||||||
|
op.drop_table("user_roles")
|
||||||
|
op.drop_table("roles")
|
||||||
|
op.drop_table("users")
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Create groups table
|
||||||
|
|
||||||
|
Revision ID: 002
|
||||||
|
Revises: 001
|
||||||
|
Create Date: 2026-01-01
|
||||||
|
|
||||||
|
Creates the groups table for syncing Authentik groups.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "002"
|
||||||
|
down_revision: Union[str, None] = "001"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Groups table
|
||||||
|
op.create_table(
|
||||||
|
"groups",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("authentik_id", postgresql.UUID(as_uuid=True), nullable=False, comment="Authentik group UUID"),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("is_superuser", sa.Boolean(), nullable=False, server_default="false", comment="Whether members have superuser privileges"),
|
||||||
|
sa.Column("parent_name", sa.String(255), nullable=True, comment="Parent group name for hierarchy"),
|
||||||
|
sa.Column("member_count", sa.Integer(), nullable=False, server_default="0", comment="Number of users in this group"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("synced_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, comment="Last sync from Authentik"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_groups_authentik_id", "groups", ["authentik_id"], unique=True)
|
||||||
|
op.create_index("ix_groups_name", "groups", ["name"], unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("groups")
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Create dashboard tables
|
||||||
|
|
||||||
|
Revision ID: 003
|
||||||
|
Revises: 002
|
||||||
|
Create Date: 2026-01-03
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '003'
|
||||||
|
down_revision: Union[str, None] = '002'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Create quick_links and dashboard_widgets tables."""
|
||||||
|
# Create quick_links table
|
||||||
|
op.create_table(
|
||||||
|
'quick_links',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('url', sa.String(length=500), nullable=False),
|
||||||
|
sa.Column('icon', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('description', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('category', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('user_id', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('position', sa.Integer(), nullable=True, default=0),
|
||||||
|
sa.Column('is_visible', sa.Boolean(), nullable=True, default=True),
|
||||||
|
sa.Column('color', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('background_color', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_quick_links_id'), 'quick_links', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_quick_links_user_id'), 'quick_links', ['user_id'], unique=False)
|
||||||
|
|
||||||
|
# Create dashboard_widgets table
|
||||||
|
op.create_table(
|
||||||
|
'dashboard_widgets',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('widget_type', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('user_id', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('position_x', sa.Integer(), nullable=True, default=0),
|
||||||
|
sa.Column('position_y', sa.Integer(), nullable=True, default=0),
|
||||||
|
sa.Column('width', sa.Integer(), nullable=True, default=1),
|
||||||
|
sa.Column('height', sa.Integer(), nullable=True, default=1),
|
||||||
|
sa.Column('config', sa.Text(), nullable=True),
|
||||||
|
sa.Column('is_visible', sa.Boolean(), nullable=True, default=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_dashboard_widgets_id'), 'dashboard_widgets', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_dashboard_widgets_user_id'), 'dashboard_widgets', ['user_id'], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Drop dashboard tables."""
|
||||||
|
op.drop_index(op.f('ix_dashboard_widgets_user_id'), table_name='dashboard_widgets')
|
||||||
|
op.drop_index(op.f('ix_dashboard_widgets_id'), table_name='dashboard_widgets')
|
||||||
|
op.drop_table('dashboard_widgets')
|
||||||
|
|
||||||
|
op.drop_index(op.f('ix_quick_links_user_id'), table_name='quick_links')
|
||||||
|
op.drop_index(op.f('ix_quick_links_id'), table_name='quick_links')
|
||||||
|
op.drop_table('quick_links')
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""add_link_type_to_quick_links
|
||||||
|
|
||||||
|
Revision ID: f0349c95aa5d
|
||||||
|
Revises: 003
|
||||||
|
Create Date: 2026-01-03 13:14:46.911770+00:00
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'f0349c95aa5d'
|
||||||
|
down_revision: Union[str, None] = '003'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('quick_links', sa.Column('link_type', sa.String(length=20), nullable=True, server_default='iframe'))
|
||||||
|
# Update existing rows to have the default value
|
||||||
|
op.execute("UPDATE quick_links SET link_type = 'iframe' WHERE link_type IS NULL")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('quick_links', 'link_type')
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""Add group_roles mapping and update role schema
|
||||||
|
|
||||||
|
Revision ID: 004
|
||||||
|
Revises: f0349c95aa5d
|
||||||
|
Create Date: 2026-01-03
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
- Add category column to roles (default 'general')
|
||||||
|
- Drop authentik_group column from roles (decoupled architecture)
|
||||||
|
- Create user_groups association table
|
||||||
|
- Create group_roles association table
|
||||||
|
- Update role names from domain:action to domain.general:action
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "004"
|
||||||
|
down_revision: Union[str, None] = "f0349c95aa5d"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add category column to roles
|
||||||
|
op.add_column(
|
||||||
|
"roles",
|
||||||
|
sa.Column(
|
||||||
|
"category",
|
||||||
|
sa.String(50),
|
||||||
|
nullable=False,
|
||||||
|
server_default="general",
|
||||||
|
comment="Permission category within domain (general for full access, or specific tool)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update role names from domain:action to domain.general:action
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE roles
|
||||||
|
SET name = REPLACE(name, ':', '.general:')
|
||||||
|
WHERE name NOT LIKE '%.%:%'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update the comment on the name column
|
||||||
|
op.alter_column(
|
||||||
|
"roles",
|
||||||
|
"name",
|
||||||
|
comment="Role name in format domain.category:action (e.g., control-room.general:admin)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Drop the authentik_group unique index first
|
||||||
|
op.drop_index("ix_roles_authentik_group", table_name="roles")
|
||||||
|
|
||||||
|
# Drop authentik_group column (no longer needed with group_roles mapping)
|
||||||
|
op.drop_column("roles", "authentik_group")
|
||||||
|
|
||||||
|
# Create user_groups association table
|
||||||
|
op.create_table(
|
||||||
|
"user_groups",
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"group_id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("groups.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create group_roles association table
|
||||||
|
op.create_table(
|
||||||
|
"group_roles",
|
||||||
|
sa.Column(
|
||||||
|
"group_id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("groups.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"role_id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("roles.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop association tables
|
||||||
|
op.drop_table("group_roles")
|
||||||
|
op.drop_table("user_groups")
|
||||||
|
|
||||||
|
# Add back authentik_group column
|
||||||
|
op.add_column(
|
||||||
|
"roles",
|
||||||
|
sa.Column(
|
||||||
|
"authentik_group",
|
||||||
|
sa.String(255),
|
||||||
|
nullable=True,
|
||||||
|
comment="Corresponding Authentik group name",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Restore authentik_group values from role names
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE roles
|
||||||
|
SET authentik_group = 'tatlock-' || REPLACE(REPLACE(name, '.general:', '-'), ':', '-')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Recreate the unique index
|
||||||
|
op.create_index("ix_roles_authentik_group", "roles", ["authentik_group"], unique=True)
|
||||||
|
|
||||||
|
# Revert role names from domain.general:action to domain:action
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE roles
|
||||||
|
SET name = REPLACE(name, '.general:', ':')
|
||||||
|
WHERE name LIKE '%.general:%'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update the comment on the name column
|
||||||
|
op.alter_column(
|
||||||
|
"roles",
|
||||||
|
"name",
|
||||||
|
comment="Role name in format domain:action",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Drop category column
|
||||||
|
op.drop_column("roles", "category")
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""Create Book tables (T-1, D-68/D-69/D-70; tenant/number amended by T-7)
|
||||||
|
|
||||||
|
Revision ID: 005
|
||||||
|
Revises: 004
|
||||||
|
Create Date: 2026-10-19
|
||||||
|
|
||||||
|
The whole Book schema lands in this one migration — book_records,
|
||||||
|
book_tags, book_record_links, book_attachments, book_events,
|
||||||
|
book_tenant_counters — so later rounds (append, remind, reschedule,
|
||||||
|
pause/resume, redact, ...) need no migration of their own for these tables.
|
||||||
|
|
||||||
|
book_events is append-only: a trigger raises on UPDATE and DELETE, for
|
||||||
|
every role including the owning application. `redact` (D-70 rule 4) is the
|
||||||
|
one sanctioned exception and is a later round; this migration does not
|
||||||
|
soften the trigger.
|
||||||
|
|
||||||
|
core-api T-7: amended before 005's first release (production was still at
|
||||||
|
004) to add book_records.owner_tenant/number and book_tenant_counters,
|
||||||
|
rather than patching them in with a later migration — see T-7's dossier.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR, UUID
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "005"
|
||||||
|
down_revision: Union[str, None] = "004"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Create the Book tables, its search column/index, and the append-only trigger."""
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"book_records",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("owner_tenant", sa.String(length=63), nullable=False),
|
||||||
|
sa.Column("number", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("kind", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("title", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("body", sa.Text(), nullable=False, server_default=""),
|
||||||
|
sa.Column("checklist", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||||
|
sa.Column("status", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("scope", sa.String(length=10), nullable=False, server_default="personal"),
|
||||||
|
sa.Column("priority", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("pinned", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("energy", sa.String(length=10), nullable=True),
|
||||||
|
sa.Column("estimate_minutes", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("next_step", sa.Text(), nullable=True),
|
||||||
|
sa.Column("start_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("all_day", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("remind_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("recurrence", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("recurrence_mode", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("remind_to", sa.String(length=63), nullable=True),
|
||||||
|
sa.Column("last_touched_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("trashed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("paused_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("paused_statuses", JSONB(), nullable=True),
|
||||||
|
sa.Column("source", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("external_id", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("colour", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("created_by", sa.String(length=63), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("source", "external_id", name="uq_book_records_source_external_id"),
|
||||||
|
sa.UniqueConstraint("owner_tenant", "number", name="uq_book_records_tenant_number"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_book_records_kind", "book_records", ["kind"])
|
||||||
|
op.create_index("ix_book_records_status", "book_records", ["status"])
|
||||||
|
op.create_index("ix_book_records_due_at", "book_records", ["due_at"])
|
||||||
|
op.create_index(
|
||||||
|
"ix_book_records_tenant_kind_status",
|
||||||
|
"book_records",
|
||||||
|
["owner_tenant", "kind", "status"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generated column — 'simple' because the household writes mixed Dutch
|
||||||
|
# and English, and a language-specific config would stem one of the two
|
||||||
|
# badly. Added via raw SQL: Alembic's op.add_column has no first-class
|
||||||
|
# GENERATED ALWAYS AS support.
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE book_records
|
||||||
|
ADD COLUMN search tsvector
|
||||||
|
GENERATED ALWAYS AS (to_tsvector('simple', coalesce(title, '') || ' ' || body)) STORED
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_book_records_search", "book_records", ["search"], postgresql_using="gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
# One row per tenant: the next `number` to allocate on book_records.
|
||||||
|
# Locked FOR UPDATE in the same transaction as the record insert it
|
||||||
|
# numbers (BookService._allocate_number) — see that method's docstring
|
||||||
|
# for why this shape is safe under concurrency.
|
||||||
|
op.create_table(
|
||||||
|
"book_tenant_counters",
|
||||||
|
sa.Column("owner_tenant", sa.String(length=63), nullable=False),
|
||||||
|
sa.Column("next_number", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
sa.PrimaryKeyConstraint("owner_tenant"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"book_tags",
|
||||||
|
sa.Column("record_id", UUID(as_uuid=True), sa.ForeignKey("book_records.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("tag", sa.String(length=63), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("record_id", "tag"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_book_tags_tag", "book_tags", ["tag"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"book_record_links",
|
||||||
|
sa.Column("from_id", UUID(as_uuid=True), sa.ForeignKey("book_records.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("relation", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("to_id", UUID(as_uuid=True), sa.ForeignKey("book_records.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("created_by", sa.String(length=63), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("from_id", "relation", "to_id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_book_record_links_to_id", "book_record_links", ["to_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"book_attachments",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("record_id", UUID(as_uuid=True), sa.ForeignKey("book_records.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("path", sa.String(length=1000), nullable=False),
|
||||||
|
sa.Column("mime", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("size", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_book_attachments_record_id", "book_attachments", ["record_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"book_events",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("record_id", UUID(as_uuid=True), sa.ForeignKey("book_records.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("identity", sa.String(length=63), nullable=False),
|
||||||
|
sa.Column("client", sa.String(length=63), nullable=True),
|
||||||
|
sa.Column("action", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("diff", JSONB(), nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_book_events_record_id", "book_events", ["record_id"])
|
||||||
|
|
||||||
|
# Append-only, for every role including the owning application.
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE FUNCTION book_events_append_only() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
RAISE EXCEPTION 'book_events is append-only: % not allowed', TG_OP;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER book_events_no_update_delete
|
||||||
|
BEFORE UPDATE OR DELETE ON book_events
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION book_events_append_only()
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Drop the Book tables, in dependency order."""
|
||||||
|
op.execute("DROP TRIGGER IF EXISTS book_events_no_update_delete ON book_events")
|
||||||
|
op.execute("DROP FUNCTION IF EXISTS book_events_append_only()")
|
||||||
|
|
||||||
|
op.drop_index("ix_book_events_record_id", table_name="book_events")
|
||||||
|
op.drop_table("book_events")
|
||||||
|
|
||||||
|
op.drop_index("ix_book_attachments_record_id", table_name="book_attachments")
|
||||||
|
op.drop_table("book_attachments")
|
||||||
|
|
||||||
|
op.drop_index("ix_book_record_links_to_id", table_name="book_record_links")
|
||||||
|
op.drop_table("book_record_links")
|
||||||
|
|
||||||
|
op.drop_index("ix_book_tags_tag", table_name="book_tags")
|
||||||
|
op.drop_table("book_tags")
|
||||||
|
|
||||||
|
op.drop_table("book_tenant_counters")
|
||||||
|
|
||||||
|
op.drop_index("ix_book_records_search", table_name="book_records")
|
||||||
|
op.drop_index("ix_book_records_tenant_kind_status", table_name="book_records")
|
||||||
|
op.drop_index("ix_book_records_due_at", table_name="book_records")
|
||||||
|
op.drop_index("ix_book_records_status", table_name="book_records")
|
||||||
|
op.drop_index("ix_book_records_kind", table_name="book_records")
|
||||||
|
op.drop_table("book_records")
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Create Scribe tables (dossier core-api T-5, D-66/D-68/D-73)
|
||||||
|
|
||||||
|
Revision ID: 006
|
||||||
|
Revises: 005
|
||||||
|
Create Date: 2026-10-19
|
||||||
|
|
||||||
|
scribe_addresses, scribe_messages, scribe_mailbox_entries — messaging only
|
||||||
|
(no groups, subscriptions, notification matrix or delivery this round;
|
||||||
|
workspace T-176). No data: seeding `jeroen` and `tower-control` is a
|
||||||
|
deploy step through `register`, not this migration.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import ARRAY, TSVECTOR
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "006"
|
||||||
|
down_revision: Union[str, None] = "005"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Create the Scribe tables, their search/topics GIN indexes."""
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"scribe_addresses",
|
||||||
|
sa.Column("owner_tenant", sa.String(length=63), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=63), nullable=False),
|
||||||
|
sa.Column("owner_consumer", sa.String(length=63), nullable=True),
|
||||||
|
sa.Column("created_by", sa.String(length=63), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("owner_tenant", "name"),
|
||||||
|
sa.UniqueConstraint("owner_tenant", "name", name="uq_scribe_addresses_tenant_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"scribe_messages",
|
||||||
|
sa.Column("id", sa.Integer(), sa.Identity(always=False), nullable=False),
|
||||||
|
sa.Column("owner_tenant", sa.String(length=63), nullable=False),
|
||||||
|
sa.Column("topics", ARRAY(sa.Text()), nullable=False, server_default=sa.text("'{}'::text[]")),
|
||||||
|
sa.Column("from_identity", sa.String(length=63), nullable=False),
|
||||||
|
sa.Column("from_consumer", sa.String(length=63), nullable=True),
|
||||||
|
sa.Column("subject", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("body", sa.Text(), nullable=False),
|
||||||
|
sa.Column("tags", ARRAY(sa.Text()), nullable=False, server_default=sa.text("'{}'::text[]")),
|
||||||
|
sa.Column("reply_to", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.ForeignKeyConstraint(["reply_to"], ["scribe_messages.id"], name="fk_scribe_messages_reply_to"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_scribe_messages_owner_tenant", "scribe_messages", ["owner_tenant"])
|
||||||
|
op.create_index(
|
||||||
|
"ix_scribe_messages_topics", "scribe_messages", ["topics"], postgresql_using="gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generated column — 'simple' because the household writes mixed Dutch
|
||||||
|
# and English, matching book_records.search (migration 005). Added via
|
||||||
|
# raw SQL: Alembic's op.add_column has no first-class GENERATED ALWAYS
|
||||||
|
# AS support.
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE scribe_messages
|
||||||
|
ADD COLUMN search tsvector
|
||||||
|
GENERATED ALWAYS AS (to_tsvector('simple', coalesce(subject, '') || ' ' || body)) STORED
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_scribe_messages_search", "scribe_messages", ["search"], postgresql_using="gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"scribe_mailbox_entries",
|
||||||
|
sa.Column("id", sa.Integer(), sa.Identity(always=False), nullable=False),
|
||||||
|
sa.Column("address", sa.String(length=63), nullable=False),
|
||||||
|
sa.Column("owner_tenant", sa.String(length=63), nullable=False),
|
||||||
|
sa.Column("message_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("tags", ARRAY(sa.Text()), nullable=False, server_default=sa.text("'{}'::text[]")),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["message_id"], ["scribe_messages.id"], name="fk_scribe_mailbox_entries_message_id"
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("address", "message_id", name="uq_scribe_mailbox_entries_address_message"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_scribe_mailbox_entries_owner_tenant_address",
|
||||||
|
"scribe_mailbox_entries",
|
||||||
|
["owner_tenant", "address"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_scribe_mailbox_entries_message_id", "scribe_mailbox_entries", ["message_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Drop the Scribe tables, in dependency order."""
|
||||||
|
op.drop_index("ix_scribe_mailbox_entries_message_id", table_name="scribe_mailbox_entries")
|
||||||
|
op.drop_index(
|
||||||
|
"ix_scribe_mailbox_entries_owner_tenant_address", table_name="scribe_mailbox_entries"
|
||||||
|
)
|
||||||
|
op.drop_table("scribe_mailbox_entries")
|
||||||
|
|
||||||
|
op.drop_index("ix_scribe_messages_search", table_name="scribe_messages")
|
||||||
|
op.drop_index("ix_scribe_messages_topics", table_name="scribe_messages")
|
||||||
|
op.drop_index("ix_scribe_messages_owner_tenant", table_name="scribe_messages")
|
||||||
|
op.drop_table("scribe_messages")
|
||||||
|
|
||||||
|
op.drop_table("scribe_addresses")
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Create the tenant registry (core-api T-8, workspace D-73)
|
||||||
|
|
||||||
|
Revision ID: 007
|
||||||
|
Revises: 006
|
||||||
|
Create Date: 2026-10-19
|
||||||
|
|
||||||
|
`tenants` and `tenant_consumers` — the registry every Book and Scribe
|
||||||
|
`owner_tenant` column now conforms to via a foreign key (`ON DELETE
|
||||||
|
RESTRICT`: a tenant with any Book or Scribe row can never be dropped out
|
||||||
|
from under it). Seeds the two tenants in use today — `jpmschweitzer`
|
||||||
|
(household, "Jeroen") and `llm_tester` (test, "Playground") — each granted
|
||||||
|
to `office-claude`, the only Office consumer configured in Authentik
|
||||||
|
(dossier core-api T-8). No write routes ship this round; provisioning a
|
||||||
|
third tenant is later (workspace T-195) and happens by hand until then.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "007"
|
||||||
|
down_revision: Union[str, None] = "006"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
# Every existing owner_tenant column (core-api T-8's dossier: "five
|
||||||
|
# owner_tenant columns exist ... find them by grep rather than trusting
|
||||||
|
# this count" — grepped and confirmed at five, here).
|
||||||
|
OWNER_TENANT_FKS = (
|
||||||
|
("book_records", "fk_book_records_owner_tenant"),
|
||||||
|
("book_tenant_counters", "fk_book_tenant_counters_owner_tenant"),
|
||||||
|
("scribe_addresses", "fk_scribe_addresses_owner_tenant"),
|
||||||
|
("scribe_messages", "fk_scribe_messages_owner_tenant"),
|
||||||
|
("scribe_mailbox_entries", "fk_scribe_mailbox_entries_owner_tenant"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Create the tenant registry, seed it, then bind every existing
|
||||||
|
owner_tenant column to it."""
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"tenants",
|
||||||
|
sa.Column("id", sa.String(length=63), nullable=False),
|
||||||
|
sa.Column("display_name", sa.Text(), nullable=False),
|
||||||
|
sa.Column("kind", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=20), nullable=False, server_default="active"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.CheckConstraint("kind IN ('household', 'person', 'test')", name="ck_tenants_kind"),
|
||||||
|
sa.CheckConstraint("status IN ('active', 'suspended')", name="ck_tenants_status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"tenant_consumers",
|
||||||
|
sa.Column("tenant_id", sa.String(length=63), nullable=False),
|
||||||
|
sa.Column("consumer", sa.String(length=63), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("tenant_id", "consumer"),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name="fk_tenant_consumers_tenant_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Seed: the two tenants in use today, both granted to office-claude —
|
||||||
|
# the only Office consumer configured in Authentik (dossier T-8).
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO tenants (id, display_name, kind, status) VALUES
|
||||||
|
('jpmschweitzer', 'Jeroen', 'household', 'active'),
|
||||||
|
('llm_tester', 'Playground', 'test', 'active')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO tenant_consumers (tenant_id, consumer) VALUES
|
||||||
|
('jpmschweitzer', 'office-claude'),
|
||||||
|
('llm_tester', 'office-claude')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Every existing owner_tenant column now conforms to the registry.
|
||||||
|
for table, fk_name in OWNER_TENANT_FKS:
|
||||||
|
op.create_foreign_key(
|
||||||
|
fk_name, table, "tenants", ["owner_tenant"], ["id"], ondelete="RESTRICT"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Unbind every owner_tenant column, then drop the registry."""
|
||||||
|
for table, fk_name in OWNER_TENANT_FKS:
|
||||||
|
op.drop_constraint(fk_name, table, type_="foreignkey")
|
||||||
|
|
||||||
|
op.drop_table("tenant_consumers")
|
||||||
|
op.drop_table("tenants")
|
||||||
Executable
+50
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Secret scan over the commits about to be pushed.
|
||||||
|
#
|
||||||
|
# Lives here rather than inside .githooks/pre-push so it can be read, run by
|
||||||
|
# hand (`make secrets`), and changed under review. A hook is a trigger; it is
|
||||||
|
# not a home for logic. Identical in every repo in this workspace (D-27).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
|
# A non-login shell — which is what git gives a hook — skips /etc/profile.d
|
||||||
|
# and never sees ~/.local/bin, where the gitleaks release tarball lands.
|
||||||
|
# Without this the scan reports "not installed" on every push.
|
||||||
|
[ -d "$HOME/.local/bin" ] && PATH="$HOME/.local/bin:$PATH"
|
||||||
|
|
||||||
|
if ! command -v gitleaks >/dev/null 2>&1; then
|
||||||
|
echo "FAIL secrets — gitleaks not installed, so this check would be a no-op pretending to pass." >&2
|
||||||
|
echo " https://github.com/gitleaks/gitleaks/releases → ~/.local/bin/gitleaks" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Scan the outgoing range, not full history. History here carries findings
|
||||||
|
# that are settled — test fixtures and vendored third-party code — and a gate
|
||||||
|
# that fails on something unfixable gets bypassed within a week. What matters
|
||||||
|
# is what is about to leave this machine.
|
||||||
|
if upstream=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null); then
|
||||||
|
range="$upstream..HEAD"
|
||||||
|
elif git rev-parse --verify --quiet origin/main >/dev/null; then
|
||||||
|
range="origin/main..HEAD"
|
||||||
|
else
|
||||||
|
range=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$range" ]; then
|
||||||
|
gitleaks dir . --redact --no-banner --exit-code 1 || {
|
||||||
|
echo "FAIL secrets — gitleaks found a credential in the working tree." >&2; exit 1; }
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -n "$(git log --oneline "$range" 2>/dev/null)" ] || exit 0
|
||||||
|
|
||||||
|
gitleaks git . --log-opts="$range" --redact --no-banner --exit-code 1 >/dev/null 2>&1 || {
|
||||||
|
echo "FAIL secrets — gitleaks found a credential in the commits being pushed." >&2
|
||||||
|
echo " inspect (values redacted): gitleaks git . --log-opts=\"$range\" --redact" >&2
|
||||||
|
echo " then remove and rotate it, or suppress deliberately:" >&2
|
||||||
|
echo " inline '# gitleaks:allow <reason>'" >&2
|
||||||
|
echo " or add the fingerprint to .gitleaksignore WITH a reason" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo " ok secrets"
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Decisions, Questions, Rejected
|
||||||
|
|
||||||
|
This directory holds structured planning records that pql parses
|
||||||
|
into pql.db. Each record is a `### [DQR]-N: Title` heading inside
|
||||||
|
a markdown file. Files live in three per-type subdirectories:
|
||||||
|
|
||||||
|
- `decisions/<domain>.md` — confirmed design decisions
|
||||||
|
- `questions/<domain>.md` — open questions that may resolve into
|
||||||
|
decisions or rejected proposals
|
||||||
|
- `rejected/<domain>.md` — rejected proposals (kept for the audit
|
||||||
|
trail)
|
||||||
|
|
||||||
|
The parser infers domain from the filename stem and record type
|
||||||
|
from the parent subdirectory.
|
||||||
|
|
||||||
|
D-records that propose implementation work link to `initiative`-type
|
||||||
|
tickets via `decision_ref`. Run `pql decisions show <id>
|
||||||
|
--with-tickets` to inspect implementation status.
|
||||||
|
|
||||||
|
## Recommended domains
|
||||||
|
|
||||||
|
Start with this canonical set; create files as records land in
|
||||||
|
each domain:
|
||||||
|
|
||||||
|
- **architecture** — structural commitments (storage, layering,
|
||||||
|
languages, libraries)
|
||||||
|
- **process** — team workflow (commits, branches, releases, reviews)
|
||||||
|
- **design** — user-facing surface (UX, UI, public APIs)
|
||||||
|
- **coding-conventions** — team-internal code shape (style, lint,
|
||||||
|
file layout)
|
||||||
|
- **testing** — quality strategy (coverage, layers, gates)
|
||||||
|
|
||||||
|
You might also want, project-permitting:
|
||||||
|
|
||||||
|
- `accessibility` — if you ship user-facing software
|
||||||
|
- `security` — if you handle user data or network surfaces
|
||||||
|
- `licensing` — if you release open-source or commercial
|
||||||
|
- `documentation` — if user-docs are non-trivial
|
||||||
|
- `deployment` — if shipping is non-trivial
|
||||||
|
- `performance` — if you have perf budgets / SLOs
|
||||||
|
|
||||||
|
<!-- pql:records (auto-generated; do not edit manually) -->
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- _(none)_
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- _(none)_
|
||||||
|
|
||||||
|
## Rejected
|
||||||
|
|
||||||
|
- _(none)_
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
proxy_buffers 8 16k;
|
||||||
|
proxy_buffer_size 32k;
|
||||||
|
|
||||||
|
# CORS headers for Flutter web
|
||||||
|
add_header Access-Control-Allow-Origin "https://home.schweitz.net" always;
|
||||||
|
add_header Access-Control-Allow-Credentials true always;
|
||||||
|
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, PATCH, OPTIONS" always;
|
||||||
|
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
|
||||||
|
|
||||||
|
if ($request_method = OPTIONS) {
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "core-api"
|
name = "core-api"
|
||||||
version = "1.3.4"
|
version = "1.12.0"
|
||||||
description = "Core Code API - Infrastructure management and tools API"
|
description = "Core Code API - Infrastructure management and tools API"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
+18
-8
@@ -1,12 +1,13 @@
|
|||||||
# FastAPI and ASGI server
|
# FastAPI and ASGI server
|
||||||
fastapi~=0.115.0
|
fastapi>=0.115.0
|
||||||
uvicorn[standard]>=0.34.0 # Updated for google-adk compatibility
|
starlette>=0.49.1 # CVE-2025-54121, CVE-2025-62727
|
||||||
pydantic>=2.11.1,<3.0.0 # Required for google-cloud-aiplatform[agent-engines]
|
uvicorn[standard]>=0.34.0
|
||||||
|
pydantic>=2.11.1,<3.0.0
|
||||||
pydantic-settings>=2.10.1
|
pydantic-settings>=2.10.1
|
||||||
|
|
||||||
# HTTP client
|
# HTTP client
|
||||||
httpx>=0.28.0 # Required for google-adk
|
httpx>=0.28.0
|
||||||
python-socketio[asyncio_client]~=5.11.0
|
python-socketio[asyncio_client]>=5.14.0 # CVE-2025-61765
|
||||||
|
|
||||||
# Web scraping
|
# Web scraping
|
||||||
beautifulsoup4~=4.12.0
|
beautifulsoup4~=4.12.0
|
||||||
@@ -20,8 +21,17 @@ python-dotenv~=1.0.0
|
|||||||
python-json-logger~=2.0.0
|
python-json-logger~=2.0.0
|
||||||
pytz~=2024.1
|
pytz~=2024.1
|
||||||
dnspython~=2.7.0
|
dnspython~=2.7.0
|
||||||
|
psutil~=6.1.0
|
||||||
|
|
||||||
# Authentication & Security
|
# Authentication & Security
|
||||||
PyJWT[crypto]~=2.9.0
|
PyJWT[crypto]>=2.9.0
|
||||||
python-jose[cryptography]~=3.3.0
|
python-jose[cryptography]>=3.4.0 # CVE PYSEC-2024-232, PYSEC-2024-233
|
||||||
cryptography~=43.0.0
|
cryptography>=44.0.1 # CVE-2024-12797
|
||||||
|
|
||||||
|
# Database
|
||||||
|
sqlalchemy[asyncio]~=2.0.0
|
||||||
|
asyncpg>=0.30.0
|
||||||
|
alembic~=1.13.0
|
||||||
|
|
||||||
|
# Vector Database
|
||||||
|
qdrant-client>=1.9.0
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
Core Code API - OpenAPI-compatible functions for Open WebUI
|
Core Code API - OpenAPI-compatible functions for Open WebUI
|
||||||
"""
|
"""
|
||||||
|
from src.shared.config import __version__
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
|
||||||
__author__ = "Core Code Team"
|
__author__ = "Core Code Team"
|
||||||
|
|||||||
@@ -3,3 +3,25 @@ Authentication module for core-api
|
|||||||
|
|
||||||
Provides OIDC/OAuth2 authentication via Authentik
|
Provides OIDC/OAuth2 authentication via Authentik
|
||||||
"""
|
"""
|
||||||
|
from src.auth.oidc import (
|
||||||
|
get_current_user,
|
||||||
|
get_admin_user,
|
||||||
|
get_optional_user,
|
||||||
|
get_forward_auth_user,
|
||||||
|
get_forward_auth_admin,
|
||||||
|
oidc_config,
|
||||||
|
)
|
||||||
|
from src.auth.service import AuthService, get_auth_service
|
||||||
|
from src.auth.controller import auth_controller
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"get_current_user",
|
||||||
|
"get_admin_user",
|
||||||
|
"get_optional_user",
|
||||||
|
"get_forward_auth_user",
|
||||||
|
"get_forward_auth_admin",
|
||||||
|
"oidc_config",
|
||||||
|
"AuthService",
|
||||||
|
"get_auth_service",
|
||||||
|
"auth_controller",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
"""
|
||||||
|
Authentication Controller
|
||||||
|
|
||||||
|
Provides authentication endpoints for OIDC token sync and user management.
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.controllers.base import BaseController
|
||||||
|
from src.logging_config import get_logger
|
||||||
|
from src.db import get_async_session
|
||||||
|
from src.auth.schemas import AuthSyncRequest, AuthSyncResponse, UsersListResponse, BulkSyncResultSchema, GroupsListResponse
|
||||||
|
from src.auth.service import AuthService
|
||||||
|
from src.auth.oidc import get_forward_auth_user
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthController(BaseController):
|
||||||
|
"""
|
||||||
|
Controller for authentication operations
|
||||||
|
|
||||||
|
Provides endpoints for:
|
||||||
|
- Token synchronization (login)
|
||||||
|
- User profile retrieval
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(prefix="/auth", tags=["Authentication"])
|
||||||
|
|
||||||
|
def create_router(self) -> APIRouter:
|
||||||
|
"""Create and configure the router"""
|
||||||
|
router = APIRouter(prefix=self.prefix, tags=self.tags)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/sync",
|
||||||
|
summary="Sync user from OIDC token",
|
||||||
|
response_model=AuthSyncResponse,
|
||||||
|
responses={
|
||||||
|
200: {"description": "User synced successfully"},
|
||||||
|
401: {"description": "Invalid or expired token"},
|
||||||
|
503: {"description": "Authentication service unavailable"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def sync_user(
|
||||||
|
request: AuthSyncRequest,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
) -> AuthSyncResponse:
|
||||||
|
"""
|
||||||
|
Synchronize user from OIDC access token
|
||||||
|
|
||||||
|
This endpoint should be called after the client obtains an access token
|
||||||
|
from Authentik. It:
|
||||||
|
1. Validates the token via Authentik's userinfo endpoint
|
||||||
|
2. Creates or updates the user in the database
|
||||||
|
3. Syncs roles from Authentik groups
|
||||||
|
4. Returns the user profile with roles and preferences
|
||||||
|
|
||||||
|
The client should store the returned user info for local use.
|
||||||
|
"""
|
||||||
|
service = AuthService(session)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Validate token with Authentik
|
||||||
|
token_info = await service.validate_token(request.access_token)
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warning(f"Token validation failed: {e}")
|
||||||
|
raise HTTPException(status_code=401, detail=str(e))
|
||||||
|
|
||||||
|
# Sync user to database
|
||||||
|
user, is_new = await service.sync_user(token_info)
|
||||||
|
|
||||||
|
# Sync roles from groups
|
||||||
|
roles = await service.sync_roles(user, token_info.groups)
|
||||||
|
|
||||||
|
# Commit the transaction
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Refresh to get relationships
|
||||||
|
await session.refresh(user, ["preferences"])
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
return AuthSyncResponse(
|
||||||
|
user=service.user_to_schema(user),
|
||||||
|
roles=service.roles_to_schema(roles),
|
||||||
|
preferences=service.preferences_to_schema(user.preferences),
|
||||||
|
is_new_user=is_new,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/users",
|
||||||
|
summary="List all users",
|
||||||
|
response_model=UsersListResponse,
|
||||||
|
responses={
|
||||||
|
200: {"description": "List of users"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def list_users(
|
||||||
|
search: Optional[str] = Query(None, description="Search by name or email"),
|
||||||
|
offset: int = Query(0, ge=0, description="Number of records to skip"),
|
||||||
|
limit: int = Query(50, ge=1, le=100, description="Maximum records to return"),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
) -> UsersListResponse:
|
||||||
|
"""
|
||||||
|
List all users who have logged in via Authentik
|
||||||
|
|
||||||
|
Returns paginated list of users with their roles.
|
||||||
|
Supports search filtering by name or email.
|
||||||
|
"""
|
||||||
|
service = AuthService(session)
|
||||||
|
items, total = await service.list_users(
|
||||||
|
search=search,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return UsersListResponse(items=items, total=total)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/users/sync-from-authentik",
|
||||||
|
summary="Bulk sync users from Authentik",
|
||||||
|
response_model=BulkSyncResultSchema,
|
||||||
|
responses={
|
||||||
|
200: {"description": "Sync completed"},
|
||||||
|
401: {"description": "Authentik API token invalid"},
|
||||||
|
503: {"description": "Authentik service unavailable"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def sync_users_from_authentik(
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
) -> BulkSyncResultSchema:
|
||||||
|
"""
|
||||||
|
Fetch all users from Authentik and sync to local database
|
||||||
|
|
||||||
|
This endpoint uses the Authentik admin API to fetch all users
|
||||||
|
and create/update them in the local database. Requires
|
||||||
|
AUTHENTIK_CORE_API_TOKEN to be configured.
|
||||||
|
|
||||||
|
Use this to initially populate users or to re-sync after
|
||||||
|
changes in Authentik.
|
||||||
|
"""
|
||||||
|
service = AuthService(session)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await service.bulk_sync_from_authentik()
|
||||||
|
logger.info(
|
||||||
|
f"Bulk sync completed: {result.created} created, "
|
||||||
|
f"{result.updated} updated, {result.failed} failed"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(f"Bulk sync failed: {e}")
|
||||||
|
raise HTTPException(status_code=401, detail=str(e))
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/groups",
|
||||||
|
summary="List all groups",
|
||||||
|
response_model=GroupsListResponse,
|
||||||
|
responses={
|
||||||
|
200: {"description": "List of groups"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def list_groups(
|
||||||
|
search: Optional[str] = Query(None, description="Search by group name"),
|
||||||
|
offset: int = Query(0, ge=0, description="Number of records to skip"),
|
||||||
|
limit: int = Query(50, ge=1, le=100, description="Maximum records to return"),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
) -> GroupsListResponse:
|
||||||
|
"""
|
||||||
|
List all groups synced from Authentik
|
||||||
|
|
||||||
|
Returns paginated list of groups with their details.
|
||||||
|
Supports search filtering by name.
|
||||||
|
"""
|
||||||
|
service = AuthService(session)
|
||||||
|
items, total = await service.list_groups(
|
||||||
|
search=search,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return GroupsListResponse(items=items, total=total)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/groups/sync-from-authentik",
|
||||||
|
summary="Bulk sync groups from Authentik",
|
||||||
|
response_model=BulkSyncResultSchema,
|
||||||
|
responses={
|
||||||
|
200: {"description": "Sync completed"},
|
||||||
|
401: {"description": "Authentik API credentials invalid"},
|
||||||
|
503: {"description": "Authentik service unavailable"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def sync_groups_from_authentik(
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
) -> BulkSyncResultSchema:
|
||||||
|
"""
|
||||||
|
Fetch all groups from Authentik and sync to local database
|
||||||
|
|
||||||
|
This endpoint uses the Authentik admin API to fetch all groups
|
||||||
|
and create/update them in the local database. Requires
|
||||||
|
AUTHENTIK_USERNAME and AUTHENTIK_PASSWORD to be configured.
|
||||||
|
|
||||||
|
Use this to populate groups or to re-sync after changes in Authentik.
|
||||||
|
"""
|
||||||
|
service = AuthService(session)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await service.bulk_sync_groups_from_authentik()
|
||||||
|
logger.info(
|
||||||
|
f"Groups bulk sync completed: {result.created} created, "
|
||||||
|
f"{result.updated} updated, {result.failed} failed"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(f"Groups bulk sync failed: {e}")
|
||||||
|
raise HTTPException(status_code=401, detail=str(e))
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/me",
|
||||||
|
summary="Get current user profile",
|
||||||
|
response_model=AuthSyncResponse,
|
||||||
|
responses={
|
||||||
|
200: {"description": "User profile"},
|
||||||
|
401: {"description": "Not authenticated"},
|
||||||
|
404: {"description": "User not found in database"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def get_me(
|
||||||
|
forward_auth_user: Optional[dict] = Depends(get_forward_auth_user),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
) -> AuthSyncResponse:
|
||||||
|
"""
|
||||||
|
Get the current authenticated user's profile
|
||||||
|
|
||||||
|
Authentication is handled by NPM forward auth with Authentik.
|
||||||
|
The proxy sets X-authentik-* headers which this endpoint reads.
|
||||||
|
|
||||||
|
For internal/LAN access (no forward auth headers), returns 401.
|
||||||
|
Use POST /auth/sync with an OIDC token for mobile app authentication.
|
||||||
|
"""
|
||||||
|
# Require forward auth for this endpoint
|
||||||
|
if forward_auth_user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Authentication required - access via authenticated proxy or use /auth/sync",
|
||||||
|
)
|
||||||
|
|
||||||
|
service = AuthService(session)
|
||||||
|
|
||||||
|
# Try to find user by Authentik UID first, then by email
|
||||||
|
user = None
|
||||||
|
uid = forward_auth_user.get("uid")
|
||||||
|
if uid:
|
||||||
|
try:
|
||||||
|
import uuid
|
||||||
|
authentik_id = uuid.UUID(uid)
|
||||||
|
user = await service.get_user_by_authentik_id(authentik_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass # Invalid UUID, try email
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
email = forward_auth_user.get("email")
|
||||||
|
if email:
|
||||||
|
user = await service.get_user_by_email(email)
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
# User authenticated with Authentik but not synced to database yet
|
||||||
|
# This can happen on first login via web
|
||||||
|
logger.info(f"User {forward_auth_user.get('email')} not found, creating from forward auth")
|
||||||
|
|
||||||
|
# Create user from forward auth headers
|
||||||
|
from src.auth.schemas import TokenInfoSchema
|
||||||
|
token_info = TokenInfoSchema(
|
||||||
|
sub=forward_auth_user.get("uid", ""),
|
||||||
|
email=forward_auth_user.get("email", ""),
|
||||||
|
name=forward_auth_user.get("name"),
|
||||||
|
groups=forward_auth_user.get("groups", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
user, _ = await service.sync_user(token_info)
|
||||||
|
await service.sync_roles(user, token_info.groups)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user, ["preferences", "roles"])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create user from forward auth: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="Failed to create user profile",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sync roles from current groups (in case they changed)
|
||||||
|
groups = forward_auth_user.get("groups", [])
|
||||||
|
roles = await service.sync_roles(user, groups)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return AuthSyncResponse(
|
||||||
|
user=service.user_to_schema(user),
|
||||||
|
roles=service.roles_to_schema(roles),
|
||||||
|
preferences=service.preferences_to_schema(user.preferences),
|
||||||
|
is_new_user=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
# Create controller instance
|
||||||
|
auth_controller = AuthController()
|
||||||
+60
-23
@@ -22,29 +22,42 @@ class OIDCConfig:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
# These will be set from environment variables in config.py
|
# These will be set from environment variables in config.py
|
||||||
self.enabled = False
|
self.enabled = False
|
||||||
self.issuer = ""
|
self.issuers: list[str] = []
|
||||||
self.audience = ""
|
self.audiences: list[str] = []
|
||||||
self.jwks_uri = ""
|
|
||||||
|
|
||||||
def configure(self, enabled: bool, issuer: str, audience: str):
|
def configure(self, enabled: bool, issuers: list[str], audiences: list[str]):
|
||||||
"""Configure OIDC settings"""
|
"""Configure OIDC settings"""
|
||||||
self.enabled = enabled
|
self.enabled = enabled
|
||||||
self.issuer = issuer
|
self.issuers = [iss.rstrip('/') for iss in issuers] # Normalize without trailing slash
|
||||||
self.audience = audience
|
self.audiences = audiences
|
||||||
self.jwks_uri = f"{issuer.rstrip('/')}/jwks/"
|
logger.info(f"OIDC configured: enabled={enabled}, issuers={self.issuers}, audiences={audiences}")
|
||||||
logger.info(f"OIDC configured: enabled={enabled}, issuer={issuer}")
|
|
||||||
|
def get_jwks_uri(self, issuer: str) -> str:
|
||||||
|
"""Get JWKS URI for a specific issuer"""
|
||||||
|
return f"{issuer.rstrip('/')}/jwks/"
|
||||||
|
|
||||||
|
def is_valid_issuer(self, issuer: str) -> bool:
|
||||||
|
"""Check if issuer is in the allowed list"""
|
||||||
|
normalized = issuer.rstrip('/')
|
||||||
|
return normalized in self.issuers
|
||||||
|
|
||||||
|
|
||||||
# Global OIDC config instance
|
# Global OIDC config instance
|
||||||
oidc_config = OIDCConfig()
|
oidc_config = OIDCConfig()
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
# Per-issuer JWKS cache
|
||||||
def get_jwks() -> Dict:
|
_jwks_cache: Dict[str, Dict] = {}
|
||||||
"""
|
|
||||||
Fetch JSON Web Key Set (JWKS) from Authentik
|
|
||||||
|
|
||||||
Cached to avoid repeated requests. Cache is cleared on server restart.
|
|
||||||
|
def get_jwks_for_issuer(issuer: str) -> Dict:
|
||||||
|
"""
|
||||||
|
Fetch JSON Web Key Set (JWKS) for a specific issuer.
|
||||||
|
|
||||||
|
Cached per-issuer to avoid repeated requests. Cache is cleared on server restart.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
issuer: The token issuer URL
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JWKS dictionary containing public keys for token verification
|
JWKS dictionary containing public keys for token verification
|
||||||
@@ -55,15 +68,24 @@ def get_jwks() -> Dict:
|
|||||||
if not oidc_config.enabled:
|
if not oidc_config.enabled:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
normalized_issuer = issuer.rstrip('/')
|
||||||
|
|
||||||
|
# Return cached JWKS if available
|
||||||
|
if normalized_issuer in _jwks_cache:
|
||||||
|
return _jwks_cache[normalized_issuer]
|
||||||
|
|
||||||
|
jwks_uri = oidc_config.get_jwks_uri(normalized_issuer)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.debug(f"Fetching JWKS from {oidc_config.jwks_uri}")
|
logger.debug(f"Fetching JWKS from {jwks_uri}")
|
||||||
response = httpx.get(oidc_config.jwks_uri, timeout=10.0)
|
response = httpx.get(jwks_uri, timeout=10.0)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
jwks = response.json()
|
jwks = response.json()
|
||||||
logger.info(f"JWKS fetched successfully ({len(jwks.get('keys', []))} keys)")
|
logger.info(f"JWKS fetched successfully for {normalized_issuer} ({len(jwks.get('keys', []))} keys)")
|
||||||
|
_jwks_cache[normalized_issuer] = jwks
|
||||||
return jwks
|
return jwks
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to fetch JWKS: {e}")
|
logger.error(f"Failed to fetch JWKS from {jwks_uri}: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
detail="Authentication service unavailable"
|
detail="Authentication service unavailable"
|
||||||
@@ -109,6 +131,21 @@ async def get_current_user(
|
|||||||
token = credentials.credentials
|
token = credentials.credentials
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# First, extract issuer and audience from unverified claims
|
||||||
|
unverified_claims = jwt.get_unverified_claims(token)
|
||||||
|
token_issuer = unverified_claims.get("iss", "")
|
||||||
|
token_audience = unverified_claims.get("aud", "")
|
||||||
|
|
||||||
|
# Validate issuer is in our allowed list
|
||||||
|
if not oidc_config.is_valid_issuer(token_issuer):
|
||||||
|
logger.warning(f"Invalid token issuer: {token_issuer}")
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token issuer")
|
||||||
|
|
||||||
|
# Validate audience is in our allowed list
|
||||||
|
if token_audience not in oidc_config.audiences:
|
||||||
|
logger.warning(f"Invalid token audience: {token_audience}")
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token audience")
|
||||||
|
|
||||||
# Decode token header to get key ID
|
# Decode token header to get key ID
|
||||||
unverified_header = jwt.get_unverified_header(token)
|
unverified_header = jwt.get_unverified_header(token)
|
||||||
kid = unverified_header.get("kid")
|
kid = unverified_header.get("kid")
|
||||||
@@ -116,8 +153,8 @@ async def get_current_user(
|
|||||||
if not kid:
|
if not kid:
|
||||||
raise HTTPException(status_code=401, detail="Invalid token format")
|
raise HTTPException(status_code=401, detail="Invalid token format")
|
||||||
|
|
||||||
# Find matching key in JWKS
|
# Find matching key in JWKS for this specific issuer
|
||||||
jwks = get_jwks()
|
jwks = get_jwks_for_issuer(token_issuer)
|
||||||
rsa_key = None
|
rsa_key = None
|
||||||
|
|
||||||
for key in jwks.get("keys", []):
|
for key in jwks.get("keys", []):
|
||||||
@@ -129,17 +166,17 @@ async def get_current_user(
|
|||||||
logger.warning(f"No matching key found for kid: {kid}")
|
logger.warning(f"No matching key found for kid: {kid}")
|
||||||
raise HTTPException(status_code=401, detail="Invalid token key")
|
raise HTTPException(status_code=401, detail="Invalid token key")
|
||||||
|
|
||||||
# Verify and decode token
|
# Verify and decode token using the token's actual issuer and audience
|
||||||
payload = jwt.decode(
|
payload = jwt.decode(
|
||||||
token,
|
token,
|
||||||
rsa_key,
|
rsa_key,
|
||||||
algorithms=["RS256"],
|
algorithms=["RS256"],
|
||||||
audience=oidc_config.audience,
|
audience=token_audience, # Use the token's audience (already validated)
|
||||||
issuer=oidc_config.issuer,
|
issuer=token_issuer, # Use the token's issuer (already validated)
|
||||||
)
|
)
|
||||||
|
|
||||||
user_email = payload.get("email", "unknown")
|
user_email = payload.get("email", "unknown")
|
||||||
logger.info(f"Authenticated user: {user_email}")
|
logger.info(f"Authenticated user: {user_email} (issuer: {token_issuer})")
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""
|
||||||
|
Authentication Schemas
|
||||||
|
|
||||||
|
Pydantic models for auth request/response payloads.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from src.base_schema import BaseSchema
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSyncRequest(BaseSchema):
|
||||||
|
"""
|
||||||
|
Request payload for POST /auth/sync
|
||||||
|
|
||||||
|
The client sends this after obtaining an OIDC token from Authentik.
|
||||||
|
The access_token is validated against Authentik's userinfo endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
access_token: str = Field(
|
||||||
|
...,
|
||||||
|
description="OIDC access token from Authentik",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RoleSchema(BaseSchema):
|
||||||
|
"""Role information in domain:action format"""
|
||||||
|
|
||||||
|
name: str = Field(..., description="Role name (e.g., 'control-room:admin')")
|
||||||
|
domain: str = Field(..., description="Permission domain (e.g., 'control-room')")
|
||||||
|
action: str = Field(..., description="Permission action (e.g., 'admin')")
|
||||||
|
|
||||||
|
|
||||||
|
class UserPreferencesSchema(BaseSchema):
|
||||||
|
"""User preferences"""
|
||||||
|
|
||||||
|
theme: str = Field(default="system", description="Theme preference: system, light, dark")
|
||||||
|
default_room: str = Field(default="front-hall", description="Default room for housekeeping")
|
||||||
|
preferences_json: dict = Field(default_factory=dict, description="Extended preferences")
|
||||||
|
|
||||||
|
|
||||||
|
class UserSchema(BaseSchema):
|
||||||
|
"""User information returned from sync"""
|
||||||
|
|
||||||
|
id: uuid.UUID = Field(..., description="Internal user ID")
|
||||||
|
authentik_id: uuid.UUID = Field(..., description="Authentik user ID")
|
||||||
|
email: str = Field(..., description="User email")
|
||||||
|
name: str = Field(..., description="Display name")
|
||||||
|
avatar_url: Optional[str] = Field(None, description="Profile picture URL")
|
||||||
|
created_at: datetime = Field(..., description="Account creation timestamp")
|
||||||
|
last_login: Optional[datetime] = Field(None, description="Last login timestamp")
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSyncResponse(BaseSchema):
|
||||||
|
"""
|
||||||
|
Response from POST /auth/sync
|
||||||
|
|
||||||
|
Contains the synced user profile, roles, and preferences.
|
||||||
|
"""
|
||||||
|
|
||||||
|
user: UserSchema = Field(..., description="User profile")
|
||||||
|
roles: list[RoleSchema] = Field(..., description="User's permission roles")
|
||||||
|
preferences: UserPreferencesSchema = Field(..., description="User preferences")
|
||||||
|
is_new_user: bool = Field(..., description="True if user was just created")
|
||||||
|
|
||||||
|
|
||||||
|
class TokenInfoSchema(BaseSchema):
|
||||||
|
"""
|
||||||
|
Token information from Authentik userinfo endpoint
|
||||||
|
|
||||||
|
This is what Authentik returns when validating an access token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
sub: str = Field(..., description="Subject (Authentik user ID)")
|
||||||
|
email: str = Field(..., description="User email")
|
||||||
|
name: Optional[str] = Field(None, description="Display name")
|
||||||
|
preferred_username: Optional[str] = Field(None, description="Username")
|
||||||
|
groups: list[str] = Field(default_factory=list, description="Group memberships")
|
||||||
|
picture: Optional[str] = Field(None, description="Profile picture URL")
|
||||||
|
|
||||||
|
|
||||||
|
class UserListItemSchema(BaseSchema):
|
||||||
|
"""User item for list display"""
|
||||||
|
|
||||||
|
id: uuid.UUID = Field(..., description="Internal user ID")
|
||||||
|
email: str = Field(..., description="User email")
|
||||||
|
name: str = Field(..., description="Display name")
|
||||||
|
avatar_url: Optional[str] = Field(None, description="Profile picture URL")
|
||||||
|
created_at: datetime = Field(..., description="Account creation timestamp")
|
||||||
|
last_login: Optional[datetime] = Field(None, description="Last login timestamp")
|
||||||
|
roles: list[str] = Field(default_factory=list, description="Role names")
|
||||||
|
|
||||||
|
|
||||||
|
class UsersListResponse(BaseSchema):
|
||||||
|
"""Response from GET /auth/users"""
|
||||||
|
|
||||||
|
items: list[UserListItemSchema] = Field(..., description="List of users")
|
||||||
|
total: int = Field(..., description="Total count of users")
|
||||||
|
|
||||||
|
|
||||||
|
class BulkSyncResultSchema(BaseSchema):
|
||||||
|
"""Result from bulk sync operation"""
|
||||||
|
|
||||||
|
created: int = Field(..., description="Number of users created")
|
||||||
|
updated: int = Field(..., description="Number of users updated")
|
||||||
|
failed: int = Field(..., description="Number of users that failed to sync")
|
||||||
|
total_in_authentik: int = Field(..., description="Total users in Authentik")
|
||||||
|
errors: list[str] = Field(default_factory=list, description="Error messages for failed syncs")
|
||||||
|
|
||||||
|
|
||||||
|
class GroupListItemSchema(BaseSchema):
|
||||||
|
"""Group item for list display"""
|
||||||
|
|
||||||
|
id: uuid.UUID = Field(..., description="Internal group ID")
|
||||||
|
authentik_id: uuid.UUID = Field(..., description="Authentik group ID")
|
||||||
|
name: str = Field(..., description="Group name")
|
||||||
|
is_superuser: bool = Field(default=False, description="Whether group has superuser privileges")
|
||||||
|
parent_name: Optional[str] = Field(None, description="Parent group name")
|
||||||
|
member_count: int = Field(default=0, description="Number of users in this group")
|
||||||
|
synced_at: datetime = Field(..., description="Last sync timestamp")
|
||||||
|
|
||||||
|
|
||||||
|
class GroupsListResponse(BaseSchema):
|
||||||
|
"""Response from GET /auth/groups"""
|
||||||
|
|
||||||
|
items: list[GroupListItemSchema] = Field(..., description="List of groups")
|
||||||
|
total: int = Field(..., description="Total count of groups")
|
||||||
@@ -0,0 +1,667 @@
|
|||||||
|
"""
|
||||||
|
Authentication Service
|
||||||
|
|
||||||
|
Business logic for user synchronization from Authentik.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from src.shared.config import get_settings
|
||||||
|
from src.logging_config import get_logger
|
||||||
|
from src.db.models import User, Role, UserPreferences, Group
|
||||||
|
from src.auth.schemas import TokenInfoSchema, UserSchema, RoleSchema, UserPreferencesSchema, UserListItemSchema, BulkSyncResultSchema, GroupListItemSchema
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class AuthService:
|
||||||
|
"""
|
||||||
|
Service for authentication and user synchronization
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- Token validation via Authentik userinfo endpoint
|
||||||
|
- User creation/update from OIDC claims
|
||||||
|
- Role synchronization from Authentik groups
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession):
|
||||||
|
"""
|
||||||
|
Initialize auth service
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Async database session
|
||||||
|
"""
|
||||||
|
self.session = session
|
||||||
|
self.userinfo_url = f"{settings.authentik_url}/application/o/userinfo/"
|
||||||
|
|
||||||
|
async def validate_token(self, access_token: str) -> TokenInfoSchema:
|
||||||
|
"""
|
||||||
|
Validate access token via Authentik userinfo endpoint
|
||||||
|
|
||||||
|
Args:
|
||||||
|
access_token: OIDC access token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Token info containing user claims
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If token is invalid or expired
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.get(
|
||||||
|
self.userinfo_url,
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise ValueError("Invalid or expired token")
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
logger.debug(f"Userinfo response: {data}")
|
||||||
|
|
||||||
|
return TokenInfoSchema(
|
||||||
|
sub=data.get("sub"),
|
||||||
|
email=data.get("email"),
|
||||||
|
name=data.get("name") or data.get("preferred_username"),
|
||||||
|
preferred_username=data.get("preferred_username"),
|
||||||
|
groups=data.get("groups", []),
|
||||||
|
picture=data.get("picture"),
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error(f"Authentik userinfo request failed: {e}")
|
||||||
|
raise ValueError(f"Token validation failed: {e.response.status_code}")
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"Authentik userinfo request error: {e}")
|
||||||
|
raise ValueError("Authentication service unavailable")
|
||||||
|
|
||||||
|
async def get_user_by_email(self, email: str) -> Optional[User]:
|
||||||
|
"""
|
||||||
|
Get user by email address
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: User email address
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User if found, None otherwise
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
select(User)
|
||||||
|
.options(selectinload(User.roles), selectinload(User.preferences))
|
||||||
|
.where(User.email == email)
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_user_by_authentik_id(self, authentik_id: uuid.UUID) -> Optional[User]:
|
||||||
|
"""
|
||||||
|
Get user by Authentik UUID
|
||||||
|
|
||||||
|
Args:
|
||||||
|
authentik_id: Authentik user UUID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User if found, None otherwise
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
select(User)
|
||||||
|
.options(selectinload(User.roles), selectinload(User.preferences))
|
||||||
|
.where(User.authentik_id == authentik_id)
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def sync_user(self, token_info: TokenInfoSchema) -> tuple[User, bool]:
|
||||||
|
"""
|
||||||
|
Create or update user from OIDC token info
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token_info: Validated token information
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (User, is_new_user)
|
||||||
|
"""
|
||||||
|
authentik_id = uuid.UUID(token_info.sub)
|
||||||
|
|
||||||
|
# Try to find existing user
|
||||||
|
stmt = (
|
||||||
|
select(User)
|
||||||
|
.options(selectinload(User.roles), selectinload(User.preferences))
|
||||||
|
.where(User.authentik_id == authentik_id)
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
is_new = user is None
|
||||||
|
|
||||||
|
if is_new:
|
||||||
|
# Create new user
|
||||||
|
user = User(
|
||||||
|
authentik_id=authentik_id,
|
||||||
|
email=token_info.email,
|
||||||
|
name=token_info.name or token_info.email,
|
||||||
|
avatar_url=token_info.picture,
|
||||||
|
last_login=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
self.session.add(user)
|
||||||
|
await self.session.flush() # Get the user ID
|
||||||
|
|
||||||
|
# Create default preferences
|
||||||
|
preferences = UserPreferences(user_id=user.id)
|
||||||
|
self.session.add(preferences)
|
||||||
|
|
||||||
|
logger.info(f"Created new user: {token_info.email}")
|
||||||
|
else:
|
||||||
|
# Update existing user
|
||||||
|
user.email = token_info.email
|
||||||
|
user.name = token_info.name or token_info.email
|
||||||
|
user.avatar_url = token_info.picture
|
||||||
|
user.last_login = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
logger.info(f"Updated existing user: {token_info.email}")
|
||||||
|
|
||||||
|
await self.session.flush()
|
||||||
|
return user, is_new
|
||||||
|
|
||||||
|
async def sync_roles(self, user: User, groups: list[str]) -> list[Role]:
|
||||||
|
"""
|
||||||
|
Synchronize user roles from Authentik groups
|
||||||
|
|
||||||
|
Maps Authentik groups (e.g., 'tatlock-control-room-admin')
|
||||||
|
to application roles (e.g., 'control-room:admin').
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User to sync roles for
|
||||||
|
groups: List of Authentik group names
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of synced Role objects
|
||||||
|
"""
|
||||||
|
# Get all roles that match the user's Authentik groups
|
||||||
|
stmt = select(Role).where(Role.authentik_group.in_(groups))
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
matching_roles = list(result.scalars().all())
|
||||||
|
|
||||||
|
# Clear existing roles and set new ones
|
||||||
|
user.roles = matching_roles
|
||||||
|
|
||||||
|
role_names = [r.name for r in matching_roles]
|
||||||
|
logger.info(f"Synced roles for {user.email}: {role_names}")
|
||||||
|
|
||||||
|
return matching_roles
|
||||||
|
|
||||||
|
def user_to_schema(self, user: User) -> UserSchema:
|
||||||
|
"""Convert User model to schema"""
|
||||||
|
return UserSchema(
|
||||||
|
id=user.id,
|
||||||
|
authentik_id=user.authentik_id,
|
||||||
|
email=user.email,
|
||||||
|
name=user.name,
|
||||||
|
avatar_url=user.avatar_url,
|
||||||
|
created_at=user.created_at,
|
||||||
|
last_login=user.last_login,
|
||||||
|
)
|
||||||
|
|
||||||
|
def roles_to_schema(self, roles: list[Role]) -> list[RoleSchema]:
|
||||||
|
"""Convert Role models to schemas"""
|
||||||
|
return [
|
||||||
|
RoleSchema(name=r.name, domain=r.domain, action=r.action)
|
||||||
|
for r in roles
|
||||||
|
]
|
||||||
|
|
||||||
|
def preferences_to_schema(self, preferences: Optional[UserPreferences]) -> UserPreferencesSchema:
|
||||||
|
"""Convert UserPreferences model to schema"""
|
||||||
|
if preferences is None:
|
||||||
|
return UserPreferencesSchema()
|
||||||
|
|
||||||
|
return UserPreferencesSchema(
|
||||||
|
theme=preferences.theme,
|
||||||
|
default_room=preferences.default_room,
|
||||||
|
preferences_json=preferences.preferences_json or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_users(
|
||||||
|
self,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> tuple[list[UserListItemSchema], int]:
|
||||||
|
"""
|
||||||
|
List all users with optional search and pagination
|
||||||
|
|
||||||
|
Args:
|
||||||
|
search: Optional search query (matches name or email)
|
||||||
|
offset: Number of records to skip
|
||||||
|
limit: Maximum number of records to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (list of user schemas, total count)
|
||||||
|
"""
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
# Base query with roles loaded
|
||||||
|
base_query = select(User).options(selectinload(User.roles))
|
||||||
|
|
||||||
|
# Apply search filter if provided
|
||||||
|
if search:
|
||||||
|
search_filter = f"%{search}%"
|
||||||
|
base_query = base_query.where(
|
||||||
|
(User.name.ilike(search_filter)) | (User.email.ilike(search_filter))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get total count
|
||||||
|
count_query = select(func.count()).select_from(base_query.subquery())
|
||||||
|
total_result = await self.session.execute(count_query)
|
||||||
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
|
# Apply pagination and ordering
|
||||||
|
query = base_query.order_by(User.name).offset(offset).limit(limit)
|
||||||
|
result = await self.session.execute(query)
|
||||||
|
users = list(result.scalars().all())
|
||||||
|
|
||||||
|
# Convert to schemas
|
||||||
|
items = [
|
||||||
|
UserListItemSchema(
|
||||||
|
id=user.id,
|
||||||
|
email=user.email,
|
||||||
|
name=user.name,
|
||||||
|
avatar_url=user.avatar_url,
|
||||||
|
created_at=user.created_at,
|
||||||
|
last_login=user.last_login,
|
||||||
|
roles=[role.name for role in user.roles],
|
||||||
|
)
|
||||||
|
for user in users
|
||||||
|
]
|
||||||
|
|
||||||
|
return items, total
|
||||||
|
|
||||||
|
def _extract_cookie(self, headers: httpx.Headers, cookie_name: str) -> str:
|
||||||
|
"""Extract a specific cookie value from Set-Cookie headers"""
|
||||||
|
for header in headers.get_list('set-cookie'):
|
||||||
|
if header.startswith(f'{cookie_name}='):
|
||||||
|
match = re.match(rf'{cookie_name}=([^;]+)', header)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
async def _authentik_session_login(self, client: httpx.AsyncClient) -> str:
|
||||||
|
"""
|
||||||
|
Authenticate with Authentik using the flow API to establish a session
|
||||||
|
|
||||||
|
Authentik's flow API requires:
|
||||||
|
1. Cookie persistence between requests (manually handled due to domain restrictions)
|
||||||
|
2. X-authentik-CSRF header set to the authentik_csrf cookie value
|
||||||
|
3. Multi-stage flow handling (identification -> password -> done)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: httpx client
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Session cookie value for subsequent API calls
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If authentication fails
|
||||||
|
"""
|
||||||
|
flow_url = f"{settings.authentik_url}/api/v3/flows/executor/default-authentication-flow/"
|
||||||
|
|
||||||
|
# Step 1: Get the initial flow challenge (this sets the session and csrf cookies)
|
||||||
|
resp = await client.get(flow_url, headers={"Accept": "application/json"})
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
# Extract cookies manually from Set-Cookie headers (bypasses domain restrictions)
|
||||||
|
session_cookie = self._extract_cookie(resp.headers, "authentik_session")
|
||||||
|
csrf_cookie = self._extract_cookie(resp.headers, "authentik_csrf")
|
||||||
|
|
||||||
|
logger.debug(f"Flow initial: component={data.get('component')}, session={bool(session_cookie)}, csrf={bool(csrf_cookie)}")
|
||||||
|
|
||||||
|
# Build headers with manual cookie and CSRF token
|
||||||
|
def build_headers():
|
||||||
|
hdrs = {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cookie": f"authentik_session={session_cookie}",
|
||||||
|
}
|
||||||
|
if csrf_cookie:
|
||||||
|
hdrs["Cookie"] += f"; authentik_csrf={csrf_cookie}"
|
||||||
|
hdrs["X-authentik-CSRF"] = csrf_cookie
|
||||||
|
return hdrs
|
||||||
|
|
||||||
|
# Step 2: Handle identification stage - submit username
|
||||||
|
if data.get("component") == "ak-stage-identification":
|
||||||
|
resp = await client.post(
|
||||||
|
flow_url,
|
||||||
|
json={"uid_field": settings.authentik_username},
|
||||||
|
headers=build_headers(),
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
# Update session cookie if new one received
|
||||||
|
new_session = self._extract_cookie(resp.headers, "authentik_session")
|
||||||
|
if new_session:
|
||||||
|
session_cookie = new_session
|
||||||
|
|
||||||
|
logger.debug(f"After username: component={data.get('component')}")
|
||||||
|
|
||||||
|
# Step 3: Handle password stage if required
|
||||||
|
if data.get("component") == "ak-stage-password":
|
||||||
|
resp = await client.post(
|
||||||
|
flow_url,
|
||||||
|
json={"password": settings.authentik_password},
|
||||||
|
headers=build_headers(),
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
# Update session cookie if new one received
|
||||||
|
new_session = self._extract_cookie(resp.headers, "authentik_session")
|
||||||
|
if new_session:
|
||||||
|
session_cookie = new_session
|
||||||
|
|
||||||
|
logger.debug(f"After password: component={data.get('component')}")
|
||||||
|
|
||||||
|
# Check for access denied
|
||||||
|
if data.get("component") == "ak-stage-access-denied":
|
||||||
|
raise ValueError("Authentik authentication failed: access denied")
|
||||||
|
|
||||||
|
# Check for redirect (successful auth)
|
||||||
|
if data.get("component") == "xak-flow-redirect" or data.get("to"):
|
||||||
|
logger.info("Successfully authenticated with Authentik via flow")
|
||||||
|
return session_cookie
|
||||||
|
|
||||||
|
# If we're still in identification stage, the username might be wrong
|
||||||
|
if data.get("component") == "ak-stage-identification":
|
||||||
|
response_errors = data.get("response_errors", {})
|
||||||
|
raise ValueError(f"Authentication stuck at identification stage: {response_errors}")
|
||||||
|
|
||||||
|
logger.info(f"Authentik flow completed with component: {data.get('component')}")
|
||||||
|
return session_cookie
|
||||||
|
|
||||||
|
async def bulk_sync_from_authentik(self) -> BulkSyncResultSchema:
|
||||||
|
"""
|
||||||
|
Fetch all users from Authentik admin API and sync to local database
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BulkSyncResultSchema with counts of created/updated/failed users
|
||||||
|
"""
|
||||||
|
if not settings.authentik_username or not settings.authentik_password:
|
||||||
|
raise ValueError("AUTHENTIK_USERNAME and AUTHENTIK_PASSWORD must be configured")
|
||||||
|
|
||||||
|
created = 0
|
||||||
|
updated = 0
|
||||||
|
failed = 0
|
||||||
|
errors = []
|
||||||
|
total_in_authentik = 0
|
||||||
|
|
||||||
|
# Step 1: Fetch all user data from Authentik API
|
||||||
|
authentik_users = []
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||||
|
# Authenticate with Authentik to get session cookie
|
||||||
|
session_cookie = await self._authentik_session_login(client)
|
||||||
|
|
||||||
|
# Fetch users from Authentik admin API using session cookie
|
||||||
|
response = await client.get(
|
||||||
|
f"{settings.authentik_url}/api/v3/core/users/",
|
||||||
|
params={"page_size": 500},
|
||||||
|
headers={
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Cookie": f"authentik_session={session_cookie}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise ValueError("Authentik API token is invalid or expired")
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
authentik_users = data.get("results", [])
|
||||||
|
total_in_authentik = data.get("pagination", {}).get("count", len(authentik_users))
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
raise ValueError(f"Authentik API error: {e.response.status_code}")
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
raise ValueError(f"Failed to connect to Authentik: {str(e)}")
|
||||||
|
|
||||||
|
# Step 2: Sync users to database (outside of httpx context to avoid greenlet issues)
|
||||||
|
for auth_user in authentik_users:
|
||||||
|
try:
|
||||||
|
# Skip service accounts and inactive users
|
||||||
|
if auth_user.get("type") in ("service_account", "internal_service_account"):
|
||||||
|
continue
|
||||||
|
if not auth_user.get("is_active", True):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract user data from Authentik
|
||||||
|
authentik_id = uuid.UUID(auth_user["uuid"])
|
||||||
|
email = auth_user.get("email") or f"{auth_user['username']}@local"
|
||||||
|
name = auth_user.get("name") or auth_user.get("username", "Unknown")
|
||||||
|
avatar_url = auth_user.get("avatar")
|
||||||
|
|
||||||
|
# Get user's groups for role mapping
|
||||||
|
groups = []
|
||||||
|
groups_summary = auth_user.get("groups_obj", [])
|
||||||
|
for group in groups_summary:
|
||||||
|
groups.append(group.get("name", ""))
|
||||||
|
|
||||||
|
# Check if user exists
|
||||||
|
stmt = select(User).where(User.authentik_id == authentik_id)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
# Create new user
|
||||||
|
user = User(
|
||||||
|
authentik_id=authentik_id,
|
||||||
|
email=email,
|
||||||
|
name=name,
|
||||||
|
avatar_url=avatar_url,
|
||||||
|
)
|
||||||
|
self.session.add(user)
|
||||||
|
await self.session.flush()
|
||||||
|
|
||||||
|
# Create default preferences
|
||||||
|
preferences = UserPreferences(user_id=user.id)
|
||||||
|
self.session.add(preferences)
|
||||||
|
created += 1
|
||||||
|
logger.info(f"Created user from Authentik: {email}")
|
||||||
|
else:
|
||||||
|
# Update existing user
|
||||||
|
user.email = email
|
||||||
|
user.name = name
|
||||||
|
user.avatar_url = avatar_url
|
||||||
|
updated += 1
|
||||||
|
logger.info(f"Updated user from Authentik: {email}")
|
||||||
|
|
||||||
|
# Sync roles from groups
|
||||||
|
await self.sync_roles(user, groups)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
failed += 1
|
||||||
|
error_msg = f"Failed to sync user {auth_user.get('username', 'unknown')}: {str(e)}"
|
||||||
|
errors.append(error_msg)
|
||||||
|
logger.warning(error_msg)
|
||||||
|
|
||||||
|
# Commit all changes
|
||||||
|
await self.session.commit()
|
||||||
|
|
||||||
|
return BulkSyncResultSchema(
|
||||||
|
created=created,
|
||||||
|
updated=updated,
|
||||||
|
failed=failed,
|
||||||
|
total_in_authentik=total_in_authentik,
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_groups(
|
||||||
|
self,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> tuple[list[GroupListItemSchema], int]:
|
||||||
|
"""
|
||||||
|
List all groups with optional search and pagination
|
||||||
|
|
||||||
|
Args:
|
||||||
|
search: Optional search query (matches name)
|
||||||
|
offset: Number of records to skip
|
||||||
|
limit: Maximum number of records to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (list of group schemas, total count)
|
||||||
|
"""
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
# Base query
|
||||||
|
base_query = select(Group)
|
||||||
|
|
||||||
|
# Apply search filter if provided
|
||||||
|
if search:
|
||||||
|
search_filter = f"%{search}%"
|
||||||
|
base_query = base_query.where(Group.name.ilike(search_filter))
|
||||||
|
|
||||||
|
# Get total count
|
||||||
|
count_query = select(func.count()).select_from(base_query.subquery())
|
||||||
|
total_result = await self.session.execute(count_query)
|
||||||
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
|
# Apply pagination and ordering
|
||||||
|
query = base_query.order_by(Group.name).offset(offset).limit(limit)
|
||||||
|
result = await self.session.execute(query)
|
||||||
|
groups = list(result.scalars().all())
|
||||||
|
|
||||||
|
# Convert to schemas
|
||||||
|
items = [
|
||||||
|
GroupListItemSchema(
|
||||||
|
id=group.id,
|
||||||
|
authentik_id=group.authentik_id,
|
||||||
|
name=group.name,
|
||||||
|
is_superuser=group.is_superuser,
|
||||||
|
parent_name=group.parent_name,
|
||||||
|
member_count=group.member_count,
|
||||||
|
synced_at=group.synced_at,
|
||||||
|
)
|
||||||
|
for group in groups
|
||||||
|
]
|
||||||
|
|
||||||
|
return items, total
|
||||||
|
|
||||||
|
async def bulk_sync_groups_from_authentik(self) -> BulkSyncResultSchema:
|
||||||
|
"""
|
||||||
|
Fetch all groups from Authentik admin API and sync to local database
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BulkSyncResultSchema with counts of created/updated/failed groups
|
||||||
|
"""
|
||||||
|
if not settings.authentik_username or not settings.authentik_password:
|
||||||
|
raise ValueError("AUTHENTIK_USERNAME and AUTHENTIK_PASSWORD must be configured")
|
||||||
|
|
||||||
|
created = 0
|
||||||
|
updated = 0
|
||||||
|
failed = 0
|
||||||
|
errors = []
|
||||||
|
total_in_authentik = 0
|
||||||
|
|
||||||
|
# Step 1: Fetch all group data from Authentik API
|
||||||
|
authentik_groups = []
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||||
|
# Authenticate with Authentik to get session cookie
|
||||||
|
session_cookie = await self._authentik_session_login(client)
|
||||||
|
|
||||||
|
# Fetch groups from Authentik admin API using session cookie
|
||||||
|
response = await client.get(
|
||||||
|
f"{settings.authentik_url}/api/v3/core/groups/",
|
||||||
|
params={"page_size": 500},
|
||||||
|
headers={
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Cookie": f"authentik_session={session_cookie}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise ValueError("Authentik API token is invalid or expired")
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
authentik_groups = data.get("results", [])
|
||||||
|
total_in_authentik = data.get("pagination", {}).get("count", len(authentik_groups))
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
raise ValueError(f"Authentik API error: {e.response.status_code}")
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
raise ValueError(f"Failed to connect to Authentik: {str(e)}")
|
||||||
|
|
||||||
|
# Step 2: Sync groups to database (outside of httpx context to avoid greenlet issues)
|
||||||
|
for auth_group in authentik_groups:
|
||||||
|
try:
|
||||||
|
# Extract group data from Authentik
|
||||||
|
authentik_id = uuid.UUID(auth_group["pk"])
|
||||||
|
name = auth_group.get("name", "Unknown")
|
||||||
|
is_superuser = auth_group.get("is_superuser", False)
|
||||||
|
parent_name = auth_group.get("parent_name")
|
||||||
|
# users field contains list of user PKs
|
||||||
|
member_count = len(auth_group.get("users", []))
|
||||||
|
|
||||||
|
# Check if group exists
|
||||||
|
stmt = select(Group).where(Group.authentik_id == authentik_id)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
group = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if group is None:
|
||||||
|
# Create new group
|
||||||
|
group = Group(
|
||||||
|
authentik_id=authentik_id,
|
||||||
|
name=name,
|
||||||
|
is_superuser=is_superuser,
|
||||||
|
parent_name=parent_name,
|
||||||
|
member_count=member_count,
|
||||||
|
)
|
||||||
|
self.session.add(group)
|
||||||
|
created += 1
|
||||||
|
logger.info(f"Created group from Authentik: {name}")
|
||||||
|
else:
|
||||||
|
# Update existing group
|
||||||
|
group.name = name
|
||||||
|
group.is_superuser = is_superuser
|
||||||
|
group.parent_name = parent_name
|
||||||
|
group.member_count = member_count
|
||||||
|
updated += 1
|
||||||
|
logger.info(f"Updated group from Authentik: {name}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
failed += 1
|
||||||
|
error_msg = f"Failed to sync group {auth_group.get('name', 'unknown')}: {str(e)}"
|
||||||
|
errors.append(error_msg)
|
||||||
|
logger.warning(error_msg)
|
||||||
|
|
||||||
|
# Commit all changes
|
||||||
|
await self.session.commit()
|
||||||
|
|
||||||
|
return BulkSyncResultSchema(
|
||||||
|
created=created,
|
||||||
|
updated=updated,
|
||||||
|
failed=failed,
|
||||||
|
total_in_authentik=total_in_authentik,
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Factory function for dependency injection
|
||||||
|
def get_auth_service(session: AsyncSession) -> AuthService:
|
||||||
|
"""Create AuthService instance with database session"""
|
||||||
|
return AuthService(session)
|
||||||
@@ -10,7 +10,7 @@ import json
|
|||||||
from typing import Optional, Dict, List, Any
|
from typing import Optional, Dict, List, Any
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
from src.config import get_settings
|
from src.shared.config import get_settings
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import httpx
|
|||||||
from typing import Optional, Dict, List, Any
|
from typing import Optional, Dict, List, Any
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
from src.config import get_settings
|
from src.shared.config import get_settings
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import httpx
|
|||||||
import json
|
import json
|
||||||
from typing import Optional, Dict, List, Any
|
from typing import Optional, Dict, List, Any
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
from src.config import get_settings
|
from src.shared.config import get_settings
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|||||||
-156
@@ -1,156 +0,0 @@
|
|||||||
"""
|
|
||||||
Global configuration for Core Code API
|
|
||||||
|
|
||||||
All configuration is loaded from environment variables or .env file.
|
|
||||||
See .env.example for available settings.
|
|
||||||
"""
|
|
||||||
import tomllib
|
|
||||||
from pathlib import Path
|
|
||||||
from pydantic_settings import BaseSettings
|
|
||||||
from functools import lru_cache
|
|
||||||
|
|
||||||
|
|
||||||
def _get_version_from_pyproject() -> str:
|
|
||||||
"""Load version from pyproject.toml"""
|
|
||||||
pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
|
|
||||||
try:
|
|
||||||
with open(pyproject_path, "rb") as f:
|
|
||||||
data = tomllib.load(f)
|
|
||||||
return data.get("project", {}).get("version", "0.0.0")
|
|
||||||
except FileNotFoundError:
|
|
||||||
return "0.0.0"
|
|
||||||
|
|
||||||
|
|
||||||
__version__ = _get_version_from_pyproject()
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
"""Global application settings"""
|
|
||||||
|
|
||||||
# Application
|
|
||||||
app_name: str = "Core Code API"
|
|
||||||
app_version: str = __version__
|
|
||||||
debug: bool = False
|
|
||||||
|
|
||||||
# Server
|
|
||||||
host: str = "0.0.0.0"
|
|
||||||
port: int = 8083
|
|
||||||
|
|
||||||
# CORS
|
|
||||||
cors_origins: list[str] = ["*"]
|
|
||||||
cors_credentials: bool = True
|
|
||||||
cors_methods: list[str] = ["*"]
|
|
||||||
cors_headers: list[str] = ["*"]
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
log_level: str = "DEBUG"
|
|
||||||
|
|
||||||
# Ollama Configuration (for AI orchestration)
|
|
||||||
ollama_base_url: str # Required - set OLLAMA_BASE_URL in .env
|
|
||||||
ollama_timeout: int = 300 # 5 minutes
|
|
||||||
|
|
||||||
# Model Configuration
|
|
||||||
default_model: str = "mistral-tools:7b"
|
|
||||||
agent_model: str = "gemma2:9b-instruct-q5_K_M" # Must support tool calling with ADK (~4GB VRAM)
|
|
||||||
lightweight_models: str = "gemma3-tools:1b,phi3:mini"
|
|
||||||
heavy_models: str = "mistral:7b,gemma2:9b,gemma3:12b,mixtral:8x7b"
|
|
||||||
code_models: str = "codestral:latest,codegemma:latest"
|
|
||||||
# Previous config (gemma3:12b used ~10GB VRAM)
|
|
||||||
# default_model: str = "gemma3:12b"
|
|
||||||
# agent_model: str = "gemma3:12b"
|
|
||||||
|
|
||||||
# System Prompt Variant (for A/B testing)
|
|
||||||
# Options: v1_verbose, v2_concise, v3_imperative, v4_minimal, v4_gemini_suggestion, v5_adk_optimized, v7_adk_best_practice, v8_holistic
|
|
||||||
system_prompt_variant: str = "v8_holistic"
|
|
||||||
|
|
||||||
# Agent Configuration
|
|
||||||
agent_fallback_enabled: bool = True
|
|
||||||
|
|
||||||
# Model Aliases (OpenAI → Local)
|
|
||||||
alias_gpt35: str = "gemma:7b"
|
|
||||||
alias_gpt4: str = "mistral:7b"
|
|
||||||
alias_gpt4_turbo: str = "mixtral:8x7b"
|
|
||||||
alias_gpt4_code: str = "codestral:latest"
|
|
||||||
|
|
||||||
# Memory Configuration
|
|
||||||
memory_tier1_max_turns: int = 10
|
|
||||||
memory_consolidation_threshold: int = 10
|
|
||||||
|
|
||||||
# Qdrant Configuration
|
|
||||||
qdrant_host: str = "qdrant"
|
|
||||||
qdrant_port: int = 6333
|
|
||||||
qdrant_collection_conversations: str = "core_api_conversations"
|
|
||||||
qdrant_collection_documents: str = "core_api_documents"
|
|
||||||
qdrant_collection_user_facts: str = "core_api_user_facts"
|
|
||||||
|
|
||||||
# Embeddings (using Ollama - no local models needed)
|
|
||||||
embedding_model: str = "nomic-embed-text" # Ollama embedding model
|
|
||||||
embedding_dimension: int = 768 # nomic-embed-text dimension
|
|
||||||
embedding_batch_size: int = 32
|
|
||||||
|
|
||||||
# Search Configuration
|
|
||||||
search_provider: str = "searxng"
|
|
||||||
searxng_url: str # Required - set SEARXNG_URL in .env
|
|
||||||
|
|
||||||
# Infrastructure Management (Portainer)
|
|
||||||
portainer_url: str # Required - set PORTAINER_URL in .env
|
|
||||||
portainer_api_key: str # Required - set PORTAINER_API_KEY in .env
|
|
||||||
|
|
||||||
# Infrastructure Management (Nginx Proxy Manager)
|
|
||||||
npm_url: str # Required - set NPM_URL in .env
|
|
||||||
npm_email: str # Required - set NPM_EMAIL in .env
|
|
||||||
npm_password: str # Required - set NPM_PASSWORD in .env
|
|
||||||
|
|
||||||
# Home Assistant Configuration
|
|
||||||
homeassistant_url: str # Required - set HOMEASSISTANT_URL in .env
|
|
||||||
homeassistant_token: str # Required - set HOMEASSISTANT_TOKEN in .env
|
|
||||||
homeassistant_timeout: int = 30
|
|
||||||
|
|
||||||
# PostgreSQL Database
|
|
||||||
postgres_host: str # Required - set POSTGRES_HOST in .env (e.g., localhost:5432)
|
|
||||||
postgres_user: str = "core_api"
|
|
||||||
postgres_password: str # Required - set POSTGRES_PASSWORD in .env
|
|
||||||
postgres_database: str = "core_api"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def database_url(self) -> str:
|
|
||||||
"""Construct database URL from components"""
|
|
||||||
return f"postgresql://{self.postgres_user}:{self.postgres_password}@{self.postgres_host}/{self.postgres_database}"
|
|
||||||
|
|
||||||
# OIDC Authentication (Authentik)
|
|
||||||
oidc_enabled: bool = False # Set to True to require authentication
|
|
||||||
oidc_issuer: str = "https://auth.schweitz.net/application/o/core-api/"
|
|
||||||
oidc_audience: str = "core-api"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def model_aliases(self) -> dict:
|
|
||||||
"""Computed property for model aliases"""
|
|
||||||
return {
|
|
||||||
"gpt-3.5-turbo": self.alias_gpt35,
|
|
||||||
"gpt-4": self.alias_gpt4,
|
|
||||||
"gpt-4-turbo": self.alias_gpt4_turbo,
|
|
||||||
"gpt-4-code": self.alias_gpt4_code,
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_lightweight_models(self) -> list[str]:
|
|
||||||
"""Parse comma-separated lightweight models"""
|
|
||||||
return [m.strip().strip('"').strip("'") for m in self.lightweight_models.split(",") if m.strip()]
|
|
||||||
|
|
||||||
def get_heavy_models(self) -> list[str]:
|
|
||||||
"""Parse comma-separated heavy models"""
|
|
||||||
return [m.strip().strip('"').strip("'") for m in self.heavy_models.split(",") if m.strip()]
|
|
||||||
|
|
||||||
def get_code_models(self) -> list[str]:
|
|
||||||
"""Parse comma-separated code models"""
|
|
||||||
return [m.strip().strip('"').strip("'") for m in self.code_models.split(",") if m.strip()]
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
env_file = ".env"
|
|
||||||
case_sensitive = False
|
|
||||||
extra = "ignore" # Ignore extra env vars not defined in Settings
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache()
|
|
||||||
def get_settings() -> Settings:
|
|
||||||
"""Cached settings instance"""
|
|
||||||
return Settings()
|
|
||||||
@@ -7,10 +7,9 @@ from fastapi import APIRouter, Response
|
|||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from src.controllers.base import BaseController
|
from src.controllers.base import BaseController
|
||||||
from src.config import get_settings
|
from src.shared.config import get_settings
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
from src.models.ollama_client import get_ollama_client
|
from src.db import get_database
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -59,96 +58,53 @@ class HealthController(BaseController):
|
|||||||
)
|
)
|
||||||
async def health_check():
|
async def health_check():
|
||||||
"""
|
"""
|
||||||
Simple health check endpoint for container orchestration
|
Fast health check endpoint for container orchestration
|
||||||
|
|
||||||
Returns a 200 OK status when the service is running properly.
|
Returns a 200 OK immediately if the service is running.
|
||||||
Used by Docker, Kubernetes, and load balancers.
|
Does NOT check backend connectivity (use /health/full for that).
|
||||||
|
Used by Docker, Kubernetes, and load balancers for liveness probes.
|
||||||
"""
|
"""
|
||||||
ollama_client = get_ollama_client()
|
|
||||||
ollama_healthy = await ollama_client.health_check()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
"version": settings.app_version,
|
"version": settings.app_version
|
||||||
"ollama_connected": ollama_healthy
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/health/full",
|
"/health/full",
|
||||||
summary="Fast health check for Docker",
|
summary="Full health check with database",
|
||||||
)
|
)
|
||||||
async def full_health_check(response: Response):
|
async def full_health_check(response: Response):
|
||||||
"""
|
"""
|
||||||
Fast health check for container orchestration (Docker/K8s).
|
Health check including database connectivity.
|
||||||
|
|
||||||
Checks component availability WITHOUT running expensive operations.
|
Returns 200 OK if database is available, otherwise 503.
|
||||||
Returns 200 OK if all components are available, otherwise 503.
|
|
||||||
|
|
||||||
For detailed diagnostics, use /health/diagnostics instead.
|
|
||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
# Check 1: Ollama connection + verify agent model is available
|
# Check database connection
|
||||||
ollama_client = get_ollama_client()
|
database = get_database()
|
||||||
ollama_healthy = False
|
db_healthy = False
|
||||||
ollama_error = None
|
db_error = None
|
||||||
model_available = False
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Ping Ollama
|
db_healthy = await database.health_check()
|
||||||
ollama_healthy = await ollama_client.health_check()
|
|
||||||
|
|
||||||
# Verify the agent model is pulled and check what's currently loaded
|
|
||||||
models_info = {}
|
|
||||||
if ollama_healthy:
|
|
||||||
try:
|
|
||||||
models_response = await ollama_client.list_models()
|
|
||||||
available_models = [m.get('name', '') for m in models_response.get('models', [])]
|
|
||||||
model_available = settings.agent_model in available_models
|
|
||||||
|
|
||||||
# Get info about currently loaded models (those with size in memory)
|
|
||||||
loaded_models = [
|
|
||||||
m.get('name', '') for m in models_response.get('models', [])
|
|
||||||
if m.get('size', 0) > 0
|
|
||||||
]
|
|
||||||
|
|
||||||
models_info = {
|
|
||||||
"configured": settings.agent_model,
|
|
||||||
"available": model_available,
|
|
||||||
"total_in_ollama": len(available_models),
|
|
||||||
"currently_loaded": loaded_models if loaded_models else ["none"]
|
|
||||||
}
|
|
||||||
|
|
||||||
if not model_available:
|
|
||||||
ollama_error = f"Model '{settings.agent_model}' not found in Ollama. Available: {', '.join(available_models[:3])}"
|
|
||||||
ollama_healthy = False
|
|
||||||
except Exception as e:
|
|
||||||
ollama_error = f"Could not list Ollama models: {str(e)}"
|
|
||||||
ollama_healthy = False
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
ollama_error = str(e)
|
db_error = str(e)
|
||||||
logger.warning(f"Ollama health check failed: {ollama_error}")
|
logger.warning(f"Database health check failed: {db_error}")
|
||||||
|
|
||||||
is_healthy = ollama_healthy
|
|
||||||
|
|
||||||
elapsed_ms = int((time.time() - start_time) * 1000)
|
elapsed_ms = int((time.time() - start_time) * 1000)
|
||||||
status_code = 200 if is_healthy else 503
|
status_code = 200 if db_healthy else 503
|
||||||
response.status_code = status_code
|
response.status_code = status_code
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "healthy" if is_healthy else "unhealthy",
|
"status": "healthy" if db_healthy else "unhealthy",
|
||||||
"status_code": status_code,
|
"status_code": status_code,
|
||||||
"response_time_ms": elapsed_ms,
|
"response_time_ms": elapsed_ms,
|
||||||
"components": {
|
"components": {
|
||||||
"ollama": {
|
"database": {
|
||||||
"status": "✅ healthy" if ollama_healthy else "❌ unhealthy",
|
"status": "healthy" if db_healthy else "unhealthy",
|
||||||
"models": models_info if models_info else {
|
"error": db_error
|
||||||
"configured": settings.agent_model,
|
|
||||||
"available": False
|
|
||||||
},
|
|
||||||
"error": ollama_error
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,18 +113,13 @@ class HealthController(BaseController):
|
|||||||
"/health/diagnostics",
|
"/health/diagnostics",
|
||||||
summary="Detailed system diagnostics",
|
summary="Detailed system diagnostics",
|
||||||
)
|
)
|
||||||
async def diagnostics(deep_test: bool = False):
|
async def diagnostics():
|
||||||
"""
|
"""
|
||||||
Comprehensive system diagnostics with detailed component information.
|
System diagnostics with service information.
|
||||||
|
|
||||||
Query Parameters:
|
|
||||||
- deep_test: Set to true to actually test agent generation (slow, ~5-10s)
|
|
||||||
|
|
||||||
Returns detailed information about all system components.
|
|
||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
diagnostics = {
|
diagnostics = {
|
||||||
"timestamp": time.time(),
|
"timestamp": time.time(),
|
||||||
"service": {
|
"service": {
|
||||||
@@ -176,30 +127,9 @@ class HealthController(BaseController):
|
|||||||
"version": settings.app_version,
|
"version": settings.app_version,
|
||||||
"purpose": "Infrastructure management and tools API"
|
"purpose": "Infrastructure management and tools API"
|
||||||
},
|
},
|
||||||
"components": {}
|
"configuration": {
|
||||||
}
|
"cors_origins": settings.cors_origins[:2] if len(settings.cors_origins) > 2 else settings.cors_origins
|
||||||
|
|
||||||
# 1. Ollama Connection
|
|
||||||
ollama_client = get_ollama_client()
|
|
||||||
try:
|
|
||||||
ollama_healthy = await ollama_client.health_check()
|
|
||||||
diagnostics["components"]["ollama"] = {
|
|
||||||
"status": "✅ connected",
|
|
||||||
"url": settings.ollama_base_url,
|
|
||||||
"timeout": settings.ollama_timeout,
|
|
||||||
"default_model": settings.default_model
|
|
||||||
}
|
}
|
||||||
except Exception as e:
|
|
||||||
diagnostics["components"]["ollama"] = {
|
|
||||||
"status": "❌ error",
|
|
||||||
"error": str(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
# 2. Configuration
|
|
||||||
diagnostics["configuration"] = {
|
|
||||||
"agent_fallback_enabled": settings.agent_fallback_enabled,
|
|
||||||
"memory_tier1_max_turns": settings.memory_tier1_max_turns,
|
|
||||||
"cors_origins": settings.cors_origins[:2] if len(settings.cors_origins) > 2 else settings.cors_origins
|
|
||||||
}
|
}
|
||||||
|
|
||||||
elapsed_ms = int((time.time() - start_time) * 1000)
|
elapsed_ms = int((time.time() - start_time) * 1000)
|
||||||
|
|||||||
@@ -3,14 +3,20 @@ Tools Controller
|
|||||||
|
|
||||||
Provides utility tool endpoints including:
|
Provides utility tool endpoints including:
|
||||||
- DNS lookups
|
- DNS lookups
|
||||||
|
- Environment data (weather, forecast, sun times, air quality)
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException, status
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
from src.controllers.base import BaseController
|
from src.controllers.base import BaseController
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
from src.dns.schemas import DNSLookupRequest, DNSLookupResponse
|
from src.dns.schemas import DNSLookupRequest, DNSLookupResponse
|
||||||
from src.dns.service import DNSService
|
from src.dns.service import DNSService
|
||||||
from src.dns.exceptions import DNSQueryError
|
from src.dns.exceptions import DNSQueryError
|
||||||
|
from src.domains.tools.environment.schemas import EnvironmentResponse
|
||||||
|
from src.domains.tools.environment.service import get_environment_service
|
||||||
|
from src.auth.oidc import get_optional_user
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -26,6 +32,7 @@ class ToolsController(BaseController):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(prefix="/tools", tags=["Tools"])
|
super().__init__(prefix="/tools", tags=["Tools"])
|
||||||
self.dns_service = DNSService()
|
self.dns_service = DNSService()
|
||||||
|
self.environment_service = get_environment_service()
|
||||||
|
|
||||||
def create_router(self) -> APIRouter:
|
def create_router(self) -> APIRouter:
|
||||||
"""Create and configure the router"""
|
"""Create and configure the router"""
|
||||||
@@ -95,6 +102,60 @@ class ToolsController(BaseController):
|
|||||||
detail="An unexpected error occurred during DNS lookup"
|
detail="An unexpected error occurred during DNS lookup"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/environment",
|
||||||
|
response_model=EnvironmentResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
summary="Get environment data",
|
||||||
|
description="""
|
||||||
|
Fetch current environment data including weather, forecast, sun times,
|
||||||
|
and optionally air quality.
|
||||||
|
|
||||||
|
Data is retrieved from the user's volatile Qdrant collection which is
|
||||||
|
populated by background data collectors.
|
||||||
|
|
||||||
|
**Data Sources:**
|
||||||
|
- Weather: Current temperature, conditions, humidity, wind
|
||||||
|
- Forecast: Multi-day weather outlook
|
||||||
|
- Sun Times: Sunrise, sunset, daylight duration
|
||||||
|
- Air Quality: AQI and pollutant levels (when available)
|
||||||
|
|
||||||
|
**Authentication:**
|
||||||
|
- Uses authenticated user's `preferred_username` if available
|
||||||
|
- Falls back to 'default' for unauthenticated requests
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
async def get_environment(
|
||||||
|
user: Optional[Dict] = Depends(get_optional_user),
|
||||||
|
) -> EnvironmentResponse:
|
||||||
|
"""
|
||||||
|
Get current environment data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: Optional authenticated user info
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Environment data with weather, forecast, sun times, and air quality
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Determine user identifier
|
||||||
|
user_id = "default"
|
||||||
|
if user:
|
||||||
|
logger.debug(f"User claims: {user}")
|
||||||
|
user_id = user.get("preferred_username") or user.get("sub", "default")
|
||||||
|
logger.info(f"Fetching environment data for user: {user_id} (preferred_username={user.get('preferred_username')}, sub={user.get('sub')})")
|
||||||
|
else:
|
||||||
|
logger.info(f"Fetching environment data for user: {user_id} (no auth)")
|
||||||
|
result = await self.environment_service.get_current(user_id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching environment data: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to fetch environment data"
|
||||||
|
)
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
Database package for Core-API
|
||||||
|
|
||||||
|
Provides async PostgreSQL database connectivity using SQLAlchemy 2.0.
|
||||||
|
"""
|
||||||
|
from src.db.database import (
|
||||||
|
get_async_session,
|
||||||
|
get_database,
|
||||||
|
Database,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"get_async_session",
|
||||||
|
"get_database",
|
||||||
|
"Database",
|
||||||
|
]
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"""
|
||||||
|
Database Connection Module
|
||||||
|
|
||||||
|
Provides async PostgreSQL connectivity using SQLAlchemy 2.0 with asyncpg driver.
|
||||||
|
Follows the existing singleton pattern used throughout core-api.
|
||||||
|
"""
|
||||||
|
from typing import AsyncGenerator, Optional
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncSession,
|
||||||
|
AsyncEngine,
|
||||||
|
create_async_engine,
|
||||||
|
async_sessionmaker,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
|
from src.shared.config import get_settings
|
||||||
|
from src.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
"""
|
||||||
|
SQLAlchemy declarative base for all models
|
||||||
|
|
||||||
|
All database models should inherit from this class.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
"""
|
||||||
|
Async database connection manager
|
||||||
|
|
||||||
|
Provides async engine and session factory for PostgreSQL connections.
|
||||||
|
Uses asyncpg driver for optimal async performance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, database_url: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Initialize database connection manager
|
||||||
|
|
||||||
|
Args:
|
||||||
|
database_url: PostgreSQL connection URL (default from settings)
|
||||||
|
"""
|
||||||
|
# Convert postgresql:// to postgresql+asyncpg:// for async driver
|
||||||
|
url = database_url or settings.database_url
|
||||||
|
if url.startswith("postgresql://"):
|
||||||
|
url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||||
|
|
||||||
|
self._url = url
|
||||||
|
self._engine: Optional[AsyncEngine] = None
|
||||||
|
self._session_factory: Optional[async_sessionmaker[AsyncSession]] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def engine(self) -> AsyncEngine:
|
||||||
|
"""
|
||||||
|
Get or create the async database engine
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AsyncEngine instance
|
||||||
|
"""
|
||||||
|
if self._engine is None:
|
||||||
|
self._engine = create_async_engine(
|
||||||
|
self._url,
|
||||||
|
echo=settings.debug, # Log SQL in debug mode
|
||||||
|
poolclass=NullPool, # Disable connection pooling for serverless compatibility
|
||||||
|
)
|
||||||
|
logger.info(f"Database engine created for {self._url.split('@')[-1]}")
|
||||||
|
return self._engine
|
||||||
|
|
||||||
|
@property
|
||||||
|
def session_factory(self) -> async_sessionmaker[AsyncSession]:
|
||||||
|
"""
|
||||||
|
Get or create the async session factory
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Session factory for creating database sessions
|
||||||
|
"""
|
||||||
|
if self._session_factory is None:
|
||||||
|
self._session_factory = async_sessionmaker(
|
||||||
|
bind=self.engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
autocommit=False,
|
||||||
|
autoflush=False,
|
||||||
|
)
|
||||||
|
return self._session_factory
|
||||||
|
|
||||||
|
async def create_tables(self) -> None:
|
||||||
|
"""
|
||||||
|
Create all database tables
|
||||||
|
|
||||||
|
Should only be used for development/testing.
|
||||||
|
Use Alembic migrations for production.
|
||||||
|
"""
|
||||||
|
async with self.engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
logger.info("Database tables created")
|
||||||
|
|
||||||
|
async def drop_tables(self) -> None:
|
||||||
|
"""
|
||||||
|
Drop all database tables
|
||||||
|
|
||||||
|
WARNING: Destroys all data. Use with caution.
|
||||||
|
"""
|
||||||
|
async with self.engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
logger.warning("Database tables dropped")
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if database connection is healthy
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if connection successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with self.session_factory() as session:
|
||||||
|
await session.execute(text("SELECT 1"))
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Database health check failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""
|
||||||
|
Close database connections and dispose of engine
|
||||||
|
"""
|
||||||
|
if self._engine is not None:
|
||||||
|
await self._engine.dispose()
|
||||||
|
self._engine = None
|
||||||
|
self._session_factory = None
|
||||||
|
logger.info("Database connections closed")
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
_database: Optional[Database] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_database() -> Database:
|
||||||
|
"""
|
||||||
|
Get singleton database instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Database instance
|
||||||
|
"""
|
||||||
|
global _database
|
||||||
|
if _database is None:
|
||||||
|
_database = Database()
|
||||||
|
return _database
|
||||||
|
|
||||||
|
|
||||||
|
async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""
|
||||||
|
FastAPI dependency for database sessions
|
||||||
|
|
||||||
|
Yields an async session that is automatically closed after the request.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@router.get("/items")
|
||||||
|
async def get_items(session: AsyncSession = Depends(get_async_session)):
|
||||||
|
result = await session.execute(select(Item))
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
AsyncSession instance
|
||||||
|
"""
|
||||||
|
database = get_database()
|
||||||
|
async with database.session_factory() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""
|
||||||
|
SQLAlchemy Models for Core-API
|
||||||
|
|
||||||
|
Database models for authentication, authorization, and user management.
|
||||||
|
"""
|
||||||
|
from src.db.models.user import User
|
||||||
|
from src.db.models.role import Role, UserRole
|
||||||
|
from src.db.models.user_preferences import UserPreferences
|
||||||
|
from src.db.models.api_key import ApiKey
|
||||||
|
from src.db.models.group import Group
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"User",
|
||||||
|
"Role",
|
||||||
|
"UserRole",
|
||||||
|
"UserPreferences",
|
||||||
|
"ApiKey",
|
||||||
|
"Group",
|
||||||
|
]
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""
|
||||||
|
API Key Model
|
||||||
|
|
||||||
|
Provides API key authentication as fallback for OIDC.
|
||||||
|
Keys are tied to user accounts and inherit user permissions.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import String, ForeignKey, DateTime, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID, ARRAY
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.database import Base
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, List
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.db.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKey(Base):
|
||||||
|
"""
|
||||||
|
API Key model for programmatic access
|
||||||
|
|
||||||
|
API keys provide an alternative to OIDC for:
|
||||||
|
- Local development without SSO
|
||||||
|
- Service-to-service communication
|
||||||
|
- Scripts and automation
|
||||||
|
|
||||||
|
Keys inherit the user's roles but can optionally
|
||||||
|
be restricted to a subset of scopes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "api_keys"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
nullable=False,
|
||||||
|
comment="Human-readable key name (e.g., 'Dev Laptop', 'CI/CD')",
|
||||||
|
)
|
||||||
|
key_hash: Mapped[str] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=False,
|
||||||
|
comment="SHA-256 hash of the API key",
|
||||||
|
)
|
||||||
|
key_prefix: Mapped[str] = mapped_column(
|
||||||
|
String(8),
|
||||||
|
nullable=False,
|
||||||
|
comment="First 8 chars of key for identification (e.g., 'cak_abc1')",
|
||||||
|
)
|
||||||
|
scopes: Mapped[List[str] | None] = mapped_column(
|
||||||
|
ARRAY(String),
|
||||||
|
nullable=True,
|
||||||
|
comment="Optional scope restriction (subset of user roles)",
|
||||||
|
)
|
||||||
|
expires_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="Optional expiration timestamp",
|
||||||
|
)
|
||||||
|
last_used_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="Last time this key was used",
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user: Mapped["User"] = relationship(
|
||||||
|
"User",
|
||||||
|
back_populates="api_keys",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<ApiKey {self.key_prefix}... ({self.name})>"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self) -> bool:
|
||||||
|
"""Check if the API key has expired"""
|
||||||
|
if self.expires_at is None:
|
||||||
|
return False
|
||||||
|
return datetime.now(self.expires_at.tzinfo) > self.expires_at
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""
|
||||||
|
Group Model
|
||||||
|
|
||||||
|
Represents groups synced from Authentik.
|
||||||
|
Groups are used for access control and user organization.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING, List
|
||||||
|
|
||||||
|
from sqlalchemy import String, Boolean, DateTime, func, Table, Column, ForeignKey
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
# Association table for User-Group many-to-many relationship
|
||||||
|
user_groups = Table(
|
||||||
|
"user_groups",
|
||||||
|
Base.metadata,
|
||||||
|
Column("user_id", UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("group_id", UUID(as_uuid=True), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Group(Base):
|
||||||
|
"""
|
||||||
|
Group model synced from Authentik
|
||||||
|
|
||||||
|
Groups are fetched from Authentik admin API and cached locally.
|
||||||
|
They represent organizational units for access control.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "groups"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
authentik_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
unique=True,
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="Authentik group UUID",
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
unique=True,
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
is_superuser: Mapped[bool] = mapped_column(
|
||||||
|
Boolean,
|
||||||
|
default=False,
|
||||||
|
nullable=False,
|
||||||
|
comment="Whether members of this group have superuser privileges",
|
||||||
|
)
|
||||||
|
parent_name: Mapped[str | None] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=True,
|
||||||
|
comment="Parent group name for hierarchy",
|
||||||
|
)
|
||||||
|
member_count: Mapped[int] = mapped_column(
|
||||||
|
default=0,
|
||||||
|
nullable=False,
|
||||||
|
comment="Number of users in this group",
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
synced_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
comment="Last sync from Authentik",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Group {self.name}>"
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""
|
||||||
|
Role Models
|
||||||
|
|
||||||
|
Defines domain-scoped permissions mapped from Authentik groups.
|
||||||
|
Format: {domain}:{action} (e.g., control-room:admin, media:viewer)
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING, List
|
||||||
|
|
||||||
|
from sqlalchemy import String, ForeignKey
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.db.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class Role(Base):
|
||||||
|
"""
|
||||||
|
Role model for domain-scoped permissions
|
||||||
|
|
||||||
|
Roles are seeded from configuration, not user-editable.
|
||||||
|
Each role maps to an Authentik group (e.g., tatlock-control-room-admin).
|
||||||
|
|
||||||
|
Domains: control-room, library, media, ai, housekeeper, developer, documents, gaming, admin
|
||||||
|
Actions: viewer, user, editor, admin (hierarchical)
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "roles"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
unique=True,
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="Role name in format domain:action (e.g., control-room:admin)",
|
||||||
|
)
|
||||||
|
domain: Mapped[str] = mapped_column(
|
||||||
|
String(50),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="Permission domain (e.g., control-room, media, ai)",
|
||||||
|
)
|
||||||
|
action: Mapped[str] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
nullable=False,
|
||||||
|
comment="Permission action (viewer, user, editor, admin)",
|
||||||
|
)
|
||||||
|
authentik_group: Mapped[str | None] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=True,
|
||||||
|
unique=True,
|
||||||
|
comment="Corresponding Authentik group name (e.g., tatlock-control-room-admin)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
users: Mapped[List["User"]] = relationship(
|
||||||
|
"User",
|
||||||
|
secondary="user_roles",
|
||||||
|
back_populates="roles",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Role {self.name}>"
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(Base):
|
||||||
|
"""
|
||||||
|
Association table for User-Role many-to-many relationship
|
||||||
|
|
||||||
|
Synced from Authentik groups during user authentication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "user_roles"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
role_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("roles.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""
|
||||||
|
User Model
|
||||||
|
|
||||||
|
Represents users synced from Authentik SSO.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING, List
|
||||||
|
|
||||||
|
from sqlalchemy import String, Boolean, DateTime, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.db.models.role import Role
|
||||||
|
from src.db.models.user_preferences import UserPreferences
|
||||||
|
from src.db.models.api_key import ApiKey
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""
|
||||||
|
User model synced from Authentik
|
||||||
|
|
||||||
|
Users are created/updated when they authenticate via OIDC.
|
||||||
|
The authentik_id links to the Authentik user record.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
authentik_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
unique=True,
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
email: Mapped[str] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
unique=True,
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
avatar_url: Mapped[str | None] = mapped_column(
|
||||||
|
String(500),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
api_keys_enabled: Mapped[bool] = mapped_column(
|
||||||
|
Boolean,
|
||||||
|
default=True,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
last_login: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
roles: Mapped[List["Role"]] = relationship(
|
||||||
|
"Role",
|
||||||
|
secondary="user_roles",
|
||||||
|
back_populates="users",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
preferences: Mapped["UserPreferences"] = relationship(
|
||||||
|
"UserPreferences",
|
||||||
|
back_populates="user",
|
||||||
|
uselist=False,
|
||||||
|
lazy="selectin",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
api_keys: Mapped[List["ApiKey"]] = relationship(
|
||||||
|
"ApiKey",
|
||||||
|
back_populates="user",
|
||||||
|
lazy="selectin",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<User {self.email}>"
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""
|
||||||
|
User Preferences Model
|
||||||
|
|
||||||
|
Stores user-specific settings like theme and default room.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import String, ForeignKey
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.database import Base
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.db.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class UserPreferences(Base):
|
||||||
|
"""
|
||||||
|
User preferences model
|
||||||
|
|
||||||
|
Stores user-specific settings that persist across sessions.
|
||||||
|
Extended settings stored in preferences_json for flexibility.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "user_preferences"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
theme: Mapped[str] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
default="system",
|
||||||
|
nullable=False,
|
||||||
|
comment="Theme preference: system, light, dark",
|
||||||
|
)
|
||||||
|
default_room: Mapped[str] = mapped_column(
|
||||||
|
String(50),
|
||||||
|
default="front-hall",
|
||||||
|
nullable=False,
|
||||||
|
comment="Default room for housekeeping features",
|
||||||
|
)
|
||||||
|
preferences_json: Mapped[dict] = mapped_column(
|
||||||
|
JSONB,
|
||||||
|
default=dict,
|
||||||
|
nullable=False,
|
||||||
|
comment="Extended preferences as JSON",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user: Mapped["User"] = relationship(
|
||||||
|
"User",
|
||||||
|
back_populates="preferences",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<UserPreferences user_id={self.user_id}>"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
Domain modules for Core-API
|
||||||
|
|
||||||
|
Each domain contains its own models, schemas, services, and controllers.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""
|
||||||
|
Authentication Domain
|
||||||
|
|
||||||
|
Provides OIDC/OAuth2 authentication via Authentik, user management,
|
||||||
|
roles, groups, and API key authentication.
|
||||||
|
"""
|
||||||
|
from src.domains.auth.oidc import (
|
||||||
|
get_current_user,
|
||||||
|
get_admin_user,
|
||||||
|
get_optional_user,
|
||||||
|
get_forward_auth_user,
|
||||||
|
get_forward_auth_admin,
|
||||||
|
oidc_config,
|
||||||
|
# Permission system
|
||||||
|
require_permission,
|
||||||
|
require_any_permission,
|
||||||
|
ACTION_HIERARCHY,
|
||||||
|
VALID_DOMAINS,
|
||||||
|
DEFAULT_CATEGORY,
|
||||||
|
)
|
||||||
|
from src.domains.auth.service import AuthService, get_auth_service
|
||||||
|
from src.domains.auth.controller import auth_controller
|
||||||
|
from src.domains.auth.callers import Caller, require_caller
|
||||||
|
from src.domains.auth.models import (
|
||||||
|
User,
|
||||||
|
Role,
|
||||||
|
UserRole,
|
||||||
|
Group,
|
||||||
|
UserPreferences,
|
||||||
|
ApiKey,
|
||||||
|
user_groups,
|
||||||
|
group_roles,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# OIDC dependencies
|
||||||
|
"get_current_user",
|
||||||
|
"get_admin_user",
|
||||||
|
"get_optional_user",
|
||||||
|
"get_forward_auth_user",
|
||||||
|
"get_forward_auth_admin",
|
||||||
|
"oidc_config",
|
||||||
|
# Permission system
|
||||||
|
"require_permission",
|
||||||
|
"require_any_permission",
|
||||||
|
"ACTION_HIERARCHY",
|
||||||
|
"VALID_DOMAINS",
|
||||||
|
"DEFAULT_CATEGORY",
|
||||||
|
# Service
|
||||||
|
"AuthService",
|
||||||
|
"get_auth_service",
|
||||||
|
# Controller
|
||||||
|
"auth_controller",
|
||||||
|
# Callers (T-1)
|
||||||
|
"Caller",
|
||||||
|
"require_caller",
|
||||||
|
# Models
|
||||||
|
"User",
|
||||||
|
"Role",
|
||||||
|
"UserRole",
|
||||||
|
"Group",
|
||||||
|
"UserPreferences",
|
||||||
|
"ApiKey",
|
||||||
|
"user_groups",
|
||||||
|
"group_roles",
|
||||||
|
]
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""
|
||||||
|
Caller Authentication
|
||||||
|
|
||||||
|
core-api has no service-key auth today: AuthService.validate_api_key is
|
||||||
|
never called in any route, api_keys stays empty, and `CORE_API_KEY` is read
|
||||||
|
by nothing. This module is the first thing that reads it.
|
||||||
|
|
||||||
|
Resolution order, static keys before any JWT parsing:
|
||||||
|
1. Bearer == office_service_key -> kind "office"
|
||||||
|
2. Bearer == tatlock_service_key -> kind "tatlock"
|
||||||
|
3. Bearer == scheduler_service_key -> kind "scheduler"
|
||||||
|
4. Otherwise -> get_current_user() -> kind "user"
|
||||||
|
|
||||||
|
Each static key is matched with hmac.compare_digest, and an unconfigured
|
||||||
|
(empty-string) key never matches — checked explicitly rather than trusting
|
||||||
|
compare_digest("", "") to be the right thing to compare, which would make an
|
||||||
|
empty configured key match an empty bearer.
|
||||||
|
|
||||||
|
core-api T-4 (workspace D-73): the Office now signs every visitor in through
|
||||||
|
Authentik with one OAuth client per consumer, and every token carries a
|
||||||
|
signed `tatlock_user` claim. The Office verifies the token and forwards the
|
||||||
|
result in two headers, trusted only alongside the office service key:
|
||||||
|
|
||||||
|
- `X-Office-Consumer` — the Authentik application slug the token came from
|
||||||
|
(`office-claude`, `office-gemini`, ...).
|
||||||
|
- `X-Office-Tenant` — the token's `tatlock_user` claim (the tatlock tenant).
|
||||||
|
|
||||||
|
For kind "office" both are required on **every** call, reads included — the
|
||||||
|
tenant scopes reads too. For every other kind they are ignored outright:
|
||||||
|
"tatlock" and "scheduler" carry no tenant (`None`); a "user" caller gets its
|
||||||
|
tenant from its own JWT's `tatlock_user` claim if it has one, else `None` —
|
||||||
|
never from a header, so a header can never put a non-Office caller into a
|
||||||
|
tenant.
|
||||||
|
|
||||||
|
`X-Office-Client` (informational, pre-T-4) is retired in favour of
|
||||||
|
`X-Office-Consumer`; nothing reads it any more.
|
||||||
|
"""
|
||||||
|
import hmac
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable, Literal, Optional
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Request, Security
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials
|
||||||
|
|
||||||
|
from src.domains.auth.oidc import get_current_user, security
|
||||||
|
from src.shared.config import get_settings
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
CallerKind = Literal["office", "tatlock", "scheduler", "user"]
|
||||||
|
|
||||||
|
# Reserved identities: the system nouns, never a caller's own identity.
|
||||||
|
RESERVED_IDENTITIES = {"book", "office", "scheduler", "tatlock"}
|
||||||
|
|
||||||
|
# ^[a-z0-9][a-z0-9-]{0,62}$, and additionally not a "group-*" shape (checked
|
||||||
|
# separately below, since the character class alone can't exclude a prefix).
|
||||||
|
_IDENTITY_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||||
|
|
||||||
|
# X-Office-Consumer: the Authentik application slug, same shape as an identity.
|
||||||
|
_CONSUMER_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||||
|
|
||||||
|
# X-Office-Tenant: tatlock's own tenant ids contain underscores (e.g.
|
||||||
|
# `llm_tester`), so the identity/consumer shape doesn't fit.
|
||||||
|
_TENANT_RE = re.compile(r"^[a-z0-9_][a-z0-9_.-]{0,62}$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Caller:
|
||||||
|
"""Who is calling, resolved once per request."""
|
||||||
|
|
||||||
|
kind: CallerKind
|
||||||
|
identity: str # stamped on every write and event
|
||||||
|
consumer: Optional[str] # X-Office-Consumer; office only, else None
|
||||||
|
tenant: Optional[str] # the tatlock tenant; office header or a user's own JWT claim
|
||||||
|
|
||||||
|
|
||||||
|
def _bad_request(code: str) -> HTTPException:
|
||||||
|
return HTTPException(status_code=400, detail={"code": code})
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_identity(identity: str) -> None:
|
||||||
|
"""Raise 400 identity_invalid for a malformed or reserved identity."""
|
||||||
|
if not _IDENTITY_RE.match(identity):
|
||||||
|
raise _bad_request("identity_invalid")
|
||||||
|
if identity.startswith("group-") or identity in RESERVED_IDENTITIES:
|
||||||
|
raise _bad_request("identity_invalid")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_office_consumer(request: Request) -> str:
|
||||||
|
"""X-Office-Consumer is required on every office call; raise if absent/malformed."""
|
||||||
|
value = request.headers.get("X-Office-Consumer")
|
||||||
|
if not value:
|
||||||
|
raise _bad_request("consumer_required")
|
||||||
|
if not _CONSUMER_RE.match(value):
|
||||||
|
raise _bad_request("consumer_invalid")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _require_office_tenant(request: Request) -> str:
|
||||||
|
"""X-Office-Tenant is required on every office call, reads included."""
|
||||||
|
value = request.headers.get("X-Office-Tenant")
|
||||||
|
if not value:
|
||||||
|
raise _bad_request("tenant_required")
|
||||||
|
if not _TENANT_RE.match(value):
|
||||||
|
raise _bad_request("tenant_invalid")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def require_caller(*allowed: CallerKind, identity_required: bool = False) -> Callable:
|
||||||
|
"""
|
||||||
|
Build a FastAPI dependency that resolves the caller and enforces it is
|
||||||
|
one of `allowed`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
allowed: caller kinds permitted on this route.
|
||||||
|
identity_required: for an "office" caller, whether X-Office-Identity
|
||||||
|
must be present (true on writes). Other kinds carry a fixed
|
||||||
|
identity and ignore this. Unrelated to consumer/tenant, which an
|
||||||
|
"office" caller must carry on every call regardless of this flag.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def dependency(
|
||||||
|
request: Request,
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(security),
|
||||||
|
) -> Caller:
|
||||||
|
settings = get_settings()
|
||||||
|
token = credentials.credentials if credentials else None
|
||||||
|
|
||||||
|
caller: Optional[Caller] = None
|
||||||
|
|
||||||
|
if (
|
||||||
|
token
|
||||||
|
and settings.office_service_key
|
||||||
|
and hmac.compare_digest(token, settings.office_service_key)
|
||||||
|
):
|
||||||
|
consumer = _require_office_consumer(request)
|
||||||
|
tenant = _require_office_tenant(request)
|
||||||
|
|
||||||
|
identity_header = request.headers.get("X-Office-Identity")
|
||||||
|
if not identity_header:
|
||||||
|
if identity_required:
|
||||||
|
raise _bad_request("identity_required")
|
||||||
|
identity = ""
|
||||||
|
else:
|
||||||
|
_validate_identity(identity_header)
|
||||||
|
identity = identity_header
|
||||||
|
caller = Caller(kind="office", identity=identity, consumer=consumer, tenant=tenant)
|
||||||
|
|
||||||
|
elif (
|
||||||
|
token
|
||||||
|
and settings.tatlock_service_key
|
||||||
|
and hmac.compare_digest(token, settings.tatlock_service_key)
|
||||||
|
):
|
||||||
|
caller = Caller(kind="tatlock", identity="tatlock", consumer=None, tenant=None)
|
||||||
|
|
||||||
|
elif (
|
||||||
|
token
|
||||||
|
and settings.scheduler_service_key
|
||||||
|
and hmac.compare_digest(token, settings.scheduler_service_key)
|
||||||
|
):
|
||||||
|
caller = Caller(kind="scheduler", identity="scheduler", consumer=None, tenant=None)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# No static key matched (or none configured) — fall through to
|
||||||
|
# the existing OIDC/JWT path. A static key never reaches here.
|
||||||
|
user = await get_current_user(credentials)
|
||||||
|
identity = (user or {}).get("preferred_username") or "user"
|
||||||
|
# The tenant comes only from the JWT's own claim — never from a
|
||||||
|
# header, which would let any caller spoof another tenant.
|
||||||
|
tenant = (user or {}).get("tatlock_user") or None
|
||||||
|
caller = Caller(kind="user", identity=identity, consumer=None, tenant=tenant)
|
||||||
|
|
||||||
|
if caller.kind not in allowed:
|
||||||
|
logger.warning(f"Caller kind '{caller.kind}' forbidden on this route (allowed: {allowed})")
|
||||||
|
raise HTTPException(status_code=403, detail={"code": "forbidden"})
|
||||||
|
|
||||||
|
return caller
|
||||||
|
|
||||||
|
return dependency
|
||||||
@@ -0,0 +1,612 @@
|
|||||||
|
"""
|
||||||
|
Authentication Controller
|
||||||
|
|
||||||
|
Provides authentication endpoints for OIDC token sync and user management.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.shared.base import BaseController
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
from src.shared.database import get_async_session
|
||||||
|
from src.domains.auth.schemas import (
|
||||||
|
AuthSyncRequest, AuthSyncResponse, UsersListResponse,
|
||||||
|
BulkSyncResultSchema, GroupsListResponse, RolesListResponse,
|
||||||
|
GroupRoleAssignmentResponse, UserProfileResponse, PreferencesUpdateRequest,
|
||||||
|
UserPreferencesSchema, ApiKeyCreateRequest, ApiKeyCreateResponse,
|
||||||
|
ApiKeysListResponse,
|
||||||
|
)
|
||||||
|
from src.domains.auth.service import AuthService
|
||||||
|
from src.domains.auth.oidc import get_current_user, get_current_user_or_forward_auth
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthController(BaseController):
|
||||||
|
"""
|
||||||
|
Controller for authentication operations
|
||||||
|
|
||||||
|
Provides endpoints for:
|
||||||
|
- Token synchronization (login)
|
||||||
|
- User profile retrieval
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(prefix="/auth", tags=["Authentication"])
|
||||||
|
|
||||||
|
def create_router(self) -> APIRouter:
|
||||||
|
"""Create and configure the router"""
|
||||||
|
router = APIRouter(prefix=self.prefix, tags=self.tags)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/sync",
|
||||||
|
summary="Sync user from OIDC token",
|
||||||
|
response_model=AuthSyncResponse,
|
||||||
|
responses={
|
||||||
|
200: {"description": "User synced successfully"},
|
||||||
|
401: {"description": "Invalid or expired token"},
|
||||||
|
503: {"description": "Authentication service unavailable"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def sync_user(
|
||||||
|
request: AuthSyncRequest,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
) -> AuthSyncResponse:
|
||||||
|
"""
|
||||||
|
Synchronize user from OIDC access token
|
||||||
|
|
||||||
|
This endpoint should be called after the client obtains an access token
|
||||||
|
from Authentik. It:
|
||||||
|
1. Validates the token via Authentik's userinfo endpoint
|
||||||
|
2. Creates or updates the user in the database
|
||||||
|
3. Syncs roles from Authentik groups
|
||||||
|
4. Returns the user profile with roles and preferences
|
||||||
|
|
||||||
|
The client should store the returned user info for local use.
|
||||||
|
"""
|
||||||
|
service = AuthService(session)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Validate token with Authentik
|
||||||
|
token_info = await service.validate_token(request.access_token)
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warning(f"Token validation failed: {e}")
|
||||||
|
raise HTTPException(status_code=401, detail=str(e))
|
||||||
|
|
||||||
|
# Sync user to database
|
||||||
|
user, is_new = await service.sync_user(token_info)
|
||||||
|
|
||||||
|
# Sync roles from groups
|
||||||
|
roles = await service.sync_roles(user, token_info.groups)
|
||||||
|
|
||||||
|
# Commit the transaction
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Refresh to get relationships
|
||||||
|
await session.refresh(user, ["preferences"])
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
return AuthSyncResponse(
|
||||||
|
user=service.user_to_schema(user),
|
||||||
|
roles=service.roles_to_schema(roles),
|
||||||
|
preferences=service.preferences_to_schema(user.preferences),
|
||||||
|
is_new_user=is_new,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/users",
|
||||||
|
summary="List all users",
|
||||||
|
response_model=UsersListResponse,
|
||||||
|
responses={
|
||||||
|
200: {"description": "List of users"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def list_users(
|
||||||
|
search: Optional[str] = Query(None, description="Search by name or email"),
|
||||||
|
offset: int = Query(0, ge=0, description="Number of records to skip"),
|
||||||
|
limit: int = Query(50, ge=1, le=100, description="Maximum records to return"),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
) -> UsersListResponse:
|
||||||
|
"""
|
||||||
|
List all users who have logged in via Authentik
|
||||||
|
|
||||||
|
Returns paginated list of users with their roles.
|
||||||
|
Supports search filtering by name or email.
|
||||||
|
"""
|
||||||
|
service = AuthService(session)
|
||||||
|
items, total = await service.list_users(
|
||||||
|
search=search,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return UsersListResponse(items=items, total=total)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/users/sync-from-authentik",
|
||||||
|
summary="Bulk sync users from Authentik",
|
||||||
|
response_model=BulkSyncResultSchema,
|
||||||
|
responses={
|
||||||
|
200: {"description": "Sync completed"},
|
||||||
|
401: {"description": "Authentik API token invalid"},
|
||||||
|
503: {"description": "Authentik service unavailable"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def sync_users_from_authentik(
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
) -> BulkSyncResultSchema:
|
||||||
|
"""
|
||||||
|
Fetch all users from Authentik and sync to local database
|
||||||
|
|
||||||
|
This endpoint uses the Authentik admin API to fetch all users
|
||||||
|
and create/update them in the local database. Requires
|
||||||
|
AUTHENTIK_CORE_API_TOKEN to be configured.
|
||||||
|
|
||||||
|
Use this to initially populate users or to re-sync after
|
||||||
|
changes in Authentik.
|
||||||
|
"""
|
||||||
|
service = AuthService(session)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await service.bulk_sync_from_authentik()
|
||||||
|
logger.info(
|
||||||
|
f"Bulk sync completed: {result.created} created, "
|
||||||
|
f"{result.updated} updated, {result.failed} failed"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(f"Bulk sync failed: {e}")
|
||||||
|
raise HTTPException(status_code=401, detail=str(e))
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/groups",
|
||||||
|
summary="List all groups",
|
||||||
|
response_model=GroupsListResponse,
|
||||||
|
responses={
|
||||||
|
200: {"description": "List of groups"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def list_groups(
|
||||||
|
search: Optional[str] = Query(None, description="Search by group name"),
|
||||||
|
offset: int = Query(0, ge=0, description="Number of records to skip"),
|
||||||
|
limit: int = Query(50, ge=1, le=100, description="Maximum records to return"),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
) -> GroupsListResponse:
|
||||||
|
"""
|
||||||
|
List all groups synced from Authentik
|
||||||
|
|
||||||
|
Returns paginated list of groups with their details.
|
||||||
|
Supports search filtering by name.
|
||||||
|
"""
|
||||||
|
service = AuthService(session)
|
||||||
|
items, total = await service.list_groups(
|
||||||
|
search=search,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return GroupsListResponse(items=items, total=total)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/groups/sync-from-authentik",
|
||||||
|
summary="Bulk sync groups from Authentik",
|
||||||
|
response_model=BulkSyncResultSchema,
|
||||||
|
responses={
|
||||||
|
200: {"description": "Sync completed"},
|
||||||
|
401: {"description": "Authentik API credentials invalid"},
|
||||||
|
503: {"description": "Authentik service unavailable"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def sync_groups_from_authentik(
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
) -> BulkSyncResultSchema:
|
||||||
|
"""
|
||||||
|
Fetch all groups from Authentik and sync to local database
|
||||||
|
|
||||||
|
This endpoint uses the Authentik admin API to fetch all groups
|
||||||
|
and create/update them in the local database. Requires
|
||||||
|
AUTHENTIK_USERNAME and AUTHENTIK_PASSWORD to be configured.
|
||||||
|
|
||||||
|
Use this to populate groups or to re-sync after changes in Authentik.
|
||||||
|
"""
|
||||||
|
service = AuthService(session)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await service.bulk_sync_groups_from_authentik()
|
||||||
|
logger.info(
|
||||||
|
f"Groups bulk sync completed: {result.created} created, "
|
||||||
|
f"{result.updated} updated, {result.failed} failed"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(f"Groups bulk sync failed: {e}")
|
||||||
|
raise HTTPException(status_code=401, detail=str(e))
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/roles",
|
||||||
|
summary="List all roles",
|
||||||
|
response_model=RolesListResponse,
|
||||||
|
responses={
|
||||||
|
200: {"description": "List of all available roles"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def list_roles(
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
) -> RolesListResponse:
|
||||||
|
"""
|
||||||
|
List all available roles in the system
|
||||||
|
|
||||||
|
Returns all domain.category:action role combinations.
|
||||||
|
Use these when assigning roles to groups.
|
||||||
|
"""
|
||||||
|
service = AuthService(session)
|
||||||
|
roles = await service.list_roles()
|
||||||
|
return RolesListResponse(
|
||||||
|
items=service.roles_to_schema(roles),
|
||||||
|
total=len(roles),
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/groups/{group_id}/roles/{role_id}",
|
||||||
|
summary="Assign role to group",
|
||||||
|
response_model=GroupRoleAssignmentResponse,
|
||||||
|
responses={
|
||||||
|
200: {"description": "Role assigned successfully"},
|
||||||
|
404: {"description": "Group or role not found"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def assign_role_to_group(
|
||||||
|
group_id: uuid.UUID = Path(..., description="Group ID"),
|
||||||
|
role_id: uuid.UUID = Path(..., description="Role ID to assign"),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
) -> GroupRoleAssignmentResponse:
|
||||||
|
"""
|
||||||
|
Assign a role to a group
|
||||||
|
|
||||||
|
All users in this group will inherit this role's permissions.
|
||||||
|
"""
|
||||||
|
service = AuthService(session)
|
||||||
|
|
||||||
|
try:
|
||||||
|
group = await service.assign_role_to_group(group_id, role_id)
|
||||||
|
await session.commit()
|
||||||
|
return GroupRoleAssignmentResponse(
|
||||||
|
group_id=group.id,
|
||||||
|
group_name=group.name,
|
||||||
|
roles=[role.name for role in group.roles],
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/groups/{group_id}/roles/{role_id}",
|
||||||
|
summary="Remove role from group",
|
||||||
|
response_model=GroupRoleAssignmentResponse,
|
||||||
|
responses={
|
||||||
|
200: {"description": "Role removed successfully"},
|
||||||
|
404: {"description": "Group or role not found"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def remove_role_from_group(
|
||||||
|
group_id: uuid.UUID = Path(..., description="Group ID"),
|
||||||
|
role_id: uuid.UUID = Path(..., description="Role ID to remove"),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
) -> GroupRoleAssignmentResponse:
|
||||||
|
"""
|
||||||
|
Remove a role from a group
|
||||||
|
|
||||||
|
Users in this group will no longer inherit this role's permissions.
|
||||||
|
"""
|
||||||
|
service = AuthService(session)
|
||||||
|
|
||||||
|
try:
|
||||||
|
group = await service.remove_role_from_group(group_id, role_id)
|
||||||
|
await session.commit()
|
||||||
|
return GroupRoleAssignmentResponse(
|
||||||
|
group_id=group.id,
|
||||||
|
group_name=group.name,
|
||||||
|
roles=[role.name for role in group.roles],
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# Phase 4: User Profile & Settings
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/users/me",
|
||||||
|
summary="Get current user profile",
|
||||||
|
response_model=UserProfileResponse,
|
||||||
|
responses={
|
||||||
|
200: {"description": "User profile with roles and preferences"},
|
||||||
|
401: {"description": "Not authenticated"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def get_current_user_profile(
|
||||||
|
user_claims: dict = Depends(get_current_user_or_forward_auth),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
) -> UserProfileResponse:
|
||||||
|
"""
|
||||||
|
Get the current authenticated user's profile
|
||||||
|
|
||||||
|
Returns the user's profile, roles, and preferences.
|
||||||
|
Supports both:
|
||||||
|
- Bearer token (mobile/native clients)
|
||||||
|
- NPM forward auth headers (web clients via proxy)
|
||||||
|
|
||||||
|
For forward auth users, auto-creates the user in the database
|
||||||
|
if they don't exist yet (first login via web).
|
||||||
|
"""
|
||||||
|
service = AuthService(session)
|
||||||
|
|
||||||
|
# Get authentik_id from claims (JWT 'sub' field or forward auth 'uid')
|
||||||
|
authentik_id_str = user_claims.get("sub")
|
||||||
|
if not authentik_id_str or authentik_id_str == "local-user":
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
|
||||||
|
try:
|
||||||
|
authentik_id = uuid.UUID(authentik_id_str)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
||||||
|
|
||||||
|
user = await service.get_user_by_authentik_id(authentik_id)
|
||||||
|
|
||||||
|
# If user not found and using forward auth, auto-create them
|
||||||
|
if user is None:
|
||||||
|
auth_method = user_claims.get("auth_method")
|
||||||
|
if auth_method == "forward_auth":
|
||||||
|
# Auto-sync user from forward auth headers
|
||||||
|
logger.info(f"Auto-creating user from forward auth: {user_claims.get('email')}")
|
||||||
|
user, is_new = await service.sync_user_from_claims(
|
||||||
|
authentik_id=authentik_id,
|
||||||
|
email=user_claims.get("email", ""),
|
||||||
|
name=user_claims.get("name", user_claims.get("preferred_username", "")),
|
||||||
|
groups=user_claims.get("groups", []),
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user, ["preferences", "roles"])
|
||||||
|
else:
|
||||||
|
# JWT auth but user not in DB - they need to sync first
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="User not found - please sync via /auth/sync first",
|
||||||
|
)
|
||||||
|
|
||||||
|
return UserProfileResponse(
|
||||||
|
user=service.user_to_schema(user),
|
||||||
|
roles=service.roles_to_schema(user.roles),
|
||||||
|
preferences=service.preferences_to_schema(user.preferences),
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/users/me/preferences",
|
||||||
|
summary="Get user preferences",
|
||||||
|
response_model=UserPreferencesSchema,
|
||||||
|
responses={
|
||||||
|
200: {"description": "User preferences"},
|
||||||
|
401: {"description": "Not authenticated"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def get_preferences(
|
||||||
|
user_claims: dict = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
) -> UserPreferencesSchema:
|
||||||
|
"""
|
||||||
|
Get the current user's preferences
|
||||||
|
"""
|
||||||
|
service = AuthService(session)
|
||||||
|
|
||||||
|
authentik_id_str = user_claims.get("sub")
|
||||||
|
if not authentik_id_str or authentik_id_str == "local-user":
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
|
||||||
|
try:
|
||||||
|
authentik_id = uuid.UUID(authentik_id_str)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
||||||
|
|
||||||
|
user = await service.get_user_by_authentik_id(authentik_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
return service.preferences_to_schema(user.preferences)
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/users/me/preferences",
|
||||||
|
summary="Update user preferences",
|
||||||
|
response_model=UserPreferencesSchema,
|
||||||
|
responses={
|
||||||
|
200: {"description": "Updated preferences"},
|
||||||
|
401: {"description": "Not authenticated"},
|
||||||
|
422: {"description": "Invalid preference value"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def update_preferences(
|
||||||
|
request: PreferencesUpdateRequest,
|
||||||
|
user_claims: dict = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
) -> UserPreferencesSchema:
|
||||||
|
"""
|
||||||
|
Update the current user's preferences
|
||||||
|
|
||||||
|
Only provided fields are updated. preferences_json is merged
|
||||||
|
with existing values (not replaced).
|
||||||
|
"""
|
||||||
|
service = AuthService(session)
|
||||||
|
|
||||||
|
authentik_id_str = user_claims.get("sub")
|
||||||
|
if not authentik_id_str or authentik_id_str == "local-user":
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
|
||||||
|
try:
|
||||||
|
authentik_id = uuid.UUID(authentik_id_str)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
||||||
|
|
||||||
|
user = await service.get_user_by_authentik_id(authentik_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
prefs = await service.update_preferences(
|
||||||
|
user_id=user.id,
|
||||||
|
theme=request.theme,
|
||||||
|
default_room=request.default_room,
|
||||||
|
preferences_json=request.preferences_json,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return service.preferences_to_schema(prefs)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=422, detail=str(e))
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# Phase 4: API Keys
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/users/me/api-keys",
|
||||||
|
summary="List user's API keys",
|
||||||
|
response_model=ApiKeysListResponse,
|
||||||
|
responses={
|
||||||
|
200: {"description": "List of API keys"},
|
||||||
|
401: {"description": "Not authenticated"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def list_api_keys(
|
||||||
|
user_claims: dict = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
) -> ApiKeysListResponse:
|
||||||
|
"""
|
||||||
|
List all API keys for the current user
|
||||||
|
|
||||||
|
Returns key metadata only - the actual key values are never
|
||||||
|
retrievable after creation.
|
||||||
|
"""
|
||||||
|
service = AuthService(session)
|
||||||
|
|
||||||
|
authentik_id_str = user_claims.get("sub")
|
||||||
|
if not authentik_id_str or authentik_id_str == "local-user":
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
|
||||||
|
try:
|
||||||
|
authentik_id = uuid.UUID(authentik_id_str)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
||||||
|
|
||||||
|
user = await service.get_user_by_authentik_id(authentik_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
keys = await service.list_user_api_keys(user.id)
|
||||||
|
return ApiKeysListResponse(
|
||||||
|
items=[service.api_key_to_schema(k) for k in keys],
|
||||||
|
total=len(keys),
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/users/me/api-keys",
|
||||||
|
summary="Create a new API key",
|
||||||
|
response_model=ApiKeyCreateResponse,
|
||||||
|
responses={
|
||||||
|
201: {"description": "API key created"},
|
||||||
|
401: {"description": "Not authenticated"},
|
||||||
|
403: {"description": "API keys disabled for user"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def create_api_key(
|
||||||
|
request: ApiKeyCreateRequest,
|
||||||
|
user_claims: dict = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
) -> ApiKeyCreateResponse:
|
||||||
|
"""
|
||||||
|
Create a new API key for the current user
|
||||||
|
|
||||||
|
**IMPORTANT**: The full API key is only returned once in this response!
|
||||||
|
Store it securely - it cannot be retrieved again.
|
||||||
|
"""
|
||||||
|
service = AuthService(session)
|
||||||
|
|
||||||
|
authentik_id_str = user_claims.get("sub")
|
||||||
|
if not authentik_id_str or authentik_id_str == "local-user":
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
|
||||||
|
try:
|
||||||
|
authentik_id = uuid.UUID(authentik_id_str)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
||||||
|
|
||||||
|
user = await service.get_user_by_authentik_id(authentik_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
api_key, full_key = await service.create_api_key(
|
||||||
|
user_id=user.id,
|
||||||
|
name=request.name,
|
||||||
|
scopes=request.scopes,
|
||||||
|
expires_in_days=request.expires_in_days,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return ApiKeyCreateResponse(
|
||||||
|
id=api_key.id,
|
||||||
|
name=api_key.name,
|
||||||
|
key=full_key, # Only time this is returned!
|
||||||
|
key_prefix=api_key.key_prefix,
|
||||||
|
scopes=api_key.scopes,
|
||||||
|
expires_at=api_key.expires_at,
|
||||||
|
created_at=api_key.created_at,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=403, detail=str(e))
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/users/me/api-keys/{key_id}",
|
||||||
|
summary="Delete an API key",
|
||||||
|
responses={
|
||||||
|
204: {"description": "API key deleted"},
|
||||||
|
401: {"description": "Not authenticated"},
|
||||||
|
404: {"description": "API key not found"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def delete_api_key(
|
||||||
|
key_id: uuid.UUID = Path(..., description="API key ID to delete"),
|
||||||
|
user_claims: dict = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""
|
||||||
|
Delete an API key
|
||||||
|
|
||||||
|
The key will be immediately invalidated.
|
||||||
|
"""
|
||||||
|
service = AuthService(session)
|
||||||
|
|
||||||
|
authentik_id_str = user_claims.get("sub")
|
||||||
|
if not authentik_id_str or authentik_id_str == "local-user":
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
|
||||||
|
try:
|
||||||
|
authentik_id = uuid.UUID(authentik_id_str)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
||||||
|
|
||||||
|
user = await service.get_user_by_authentik_id(authentik_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
deleted = await service.delete_api_key(user.id, key_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="API key not found")
|
||||||
|
await session.commit()
|
||||||
|
return JSONResponse(status_code=204, content=None)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=403, detail=str(e))
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
# Create controller instance
|
||||||
|
auth_controller = AuthController()
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
"""
|
||||||
|
Authentication Domain Models
|
||||||
|
|
||||||
|
SQLAlchemy models for users, roles, groups, API keys, and preferences.
|
||||||
|
All authentication-related database models consolidated in one file.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING, List
|
||||||
|
|
||||||
|
from sqlalchemy import String, Boolean, DateTime, func, ForeignKey, Table, Column
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID, JSONB, ARRAY
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.shared.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Association Tables
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
user_groups = Table(
|
||||||
|
"user_groups",
|
||||||
|
Base.metadata,
|
||||||
|
Column("user_id", UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("group_id", UUID(as_uuid=True), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
group_roles = Table(
|
||||||
|
"group_roles",
|
||||||
|
Base.metadata,
|
||||||
|
Column("group_id", UUID(as_uuid=True), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("role_id", UUID(as_uuid=True), ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# User Model
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""
|
||||||
|
User model synced from Authentik
|
||||||
|
|
||||||
|
Users are created/updated when they authenticate via OIDC.
|
||||||
|
The authentik_id links to the Authentik user record.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
authentik_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
unique=True,
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
email: Mapped[str] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
unique=True,
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
avatar_url: Mapped[str | None] = mapped_column(
|
||||||
|
String(500),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
api_keys_enabled: Mapped[bool] = mapped_column(
|
||||||
|
Boolean,
|
||||||
|
default=True,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
last_login: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
roles: Mapped[List["Role"]] = relationship(
|
||||||
|
"Role",
|
||||||
|
secondary="user_roles",
|
||||||
|
back_populates="users",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
preferences: Mapped["UserPreferences"] = relationship(
|
||||||
|
"UserPreferences",
|
||||||
|
back_populates="user",
|
||||||
|
uselist=False,
|
||||||
|
lazy="selectin",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
api_keys: Mapped[List["ApiKey"]] = relationship(
|
||||||
|
"ApiKey",
|
||||||
|
back_populates="user",
|
||||||
|
lazy="selectin",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<User {self.email}>"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Role Models
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class Role(Base):
|
||||||
|
"""
|
||||||
|
Role model for domain-scoped permissions
|
||||||
|
|
||||||
|
Permission format: domain.category:action
|
||||||
|
- domain: Main area (control-room, library, media, ai, etc.)
|
||||||
|
- category: Sub-area within domain (general for full access, or specific tools)
|
||||||
|
- action: Permission level (viewer, user, editor, admin)
|
||||||
|
|
||||||
|
Roles are seeded from configuration, not user-editable.
|
||||||
|
Groups are assigned roles via the group_roles mapping table.
|
||||||
|
|
||||||
|
Domains: control-room, library, media, ai, housekeeper, developer, documents, gaming, admin
|
||||||
|
Actions: viewer, user, editor, admin (hierarchical)
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "roles"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
unique=True,
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="Role name in format domain.category:action (e.g., control-room.general:admin)",
|
||||||
|
)
|
||||||
|
domain: Mapped[str] = mapped_column(
|
||||||
|
String(50),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="Permission domain (e.g., control-room, media, ai)",
|
||||||
|
)
|
||||||
|
category: Mapped[str] = mapped_column(
|
||||||
|
String(50),
|
||||||
|
nullable=False,
|
||||||
|
default="general",
|
||||||
|
comment="Permission category within domain (general for full access, or specific tool)",
|
||||||
|
)
|
||||||
|
action: Mapped[str] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
nullable=False,
|
||||||
|
comment="Permission action (viewer, user, editor, admin)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
users: Mapped[List["User"]] = relationship(
|
||||||
|
"User",
|
||||||
|
secondary="user_roles",
|
||||||
|
back_populates="roles",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
groups: Mapped[List["Group"]] = relationship(
|
||||||
|
"Group",
|
||||||
|
secondary="group_roles",
|
||||||
|
back_populates="roles",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Role {self.name}>"
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(Base):
|
||||||
|
"""
|
||||||
|
Association table for User-Role many-to-many relationship
|
||||||
|
|
||||||
|
Synced from Authentik groups during user authentication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "user_roles"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
role_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("roles.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Group Model
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class Group(Base):
|
||||||
|
"""
|
||||||
|
Group model synced from Authentik
|
||||||
|
|
||||||
|
Groups are fetched from Authentik admin API and cached locally.
|
||||||
|
They represent organizational units for access control.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "groups"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
authentik_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
unique=True,
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="Authentik group UUID",
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
unique=True,
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
is_superuser: Mapped[bool] = mapped_column(
|
||||||
|
Boolean,
|
||||||
|
default=False,
|
||||||
|
nullable=False,
|
||||||
|
comment="Whether members of this group have superuser privileges",
|
||||||
|
)
|
||||||
|
parent_name: Mapped[str | None] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=True,
|
||||||
|
comment="Parent group name for hierarchy",
|
||||||
|
)
|
||||||
|
member_count: Mapped[int] = mapped_column(
|
||||||
|
default=0,
|
||||||
|
nullable=False,
|
||||||
|
comment="Number of users in this group",
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
synced_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
comment="Last sync from Authentik",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
roles: Mapped[List["Role"]] = relationship(
|
||||||
|
"Role",
|
||||||
|
secondary="group_roles",
|
||||||
|
back_populates="groups",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Group {self.name}>"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# User Preferences Model
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class UserPreferences(Base):
|
||||||
|
"""
|
||||||
|
User preferences model
|
||||||
|
|
||||||
|
Stores user-specific settings that persist across sessions.
|
||||||
|
Extended settings stored in preferences_json for flexibility.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "user_preferences"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
theme: Mapped[str] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
default="system",
|
||||||
|
nullable=False,
|
||||||
|
comment="Theme preference: system, light, dark",
|
||||||
|
)
|
||||||
|
default_room: Mapped[str] = mapped_column(
|
||||||
|
String(50),
|
||||||
|
default="front-hall",
|
||||||
|
nullable=False,
|
||||||
|
comment="Default room for housekeeping features",
|
||||||
|
)
|
||||||
|
preferences_json: Mapped[dict] = mapped_column(
|
||||||
|
JSONB,
|
||||||
|
default=dict,
|
||||||
|
nullable=False,
|
||||||
|
comment="Extended preferences as JSON",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user: Mapped["User"] = relationship(
|
||||||
|
"User",
|
||||||
|
back_populates="preferences",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<UserPreferences user_id={self.user_id}>"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# API Key Model
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class ApiKey(Base):
|
||||||
|
"""
|
||||||
|
API Key model for programmatic access
|
||||||
|
|
||||||
|
API keys provide an alternative to OIDC for:
|
||||||
|
- Local development without SSO
|
||||||
|
- Service-to-service communication
|
||||||
|
- Scripts and automation
|
||||||
|
|
||||||
|
Keys inherit the user's roles but can optionally
|
||||||
|
be restricted to a subset of scopes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "api_keys"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
nullable=False,
|
||||||
|
comment="Human-readable key name (e.g., 'Dev Laptop', 'CI/CD')",
|
||||||
|
)
|
||||||
|
key_hash: Mapped[str] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=False,
|
||||||
|
comment="SHA-256 hash of the API key",
|
||||||
|
)
|
||||||
|
key_prefix: Mapped[str] = mapped_column(
|
||||||
|
String(8),
|
||||||
|
nullable=False,
|
||||||
|
comment="First 8 chars of key for identification (e.g., 'cak_abc1')",
|
||||||
|
)
|
||||||
|
scopes: Mapped[List[str] | None] = mapped_column(
|
||||||
|
ARRAY(String),
|
||||||
|
nullable=True,
|
||||||
|
comment="Optional scope restriction (subset of user roles)",
|
||||||
|
)
|
||||||
|
expires_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="Optional expiration timestamp",
|
||||||
|
)
|
||||||
|
last_used_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="Last time this key was used",
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user: Mapped["User"] = relationship(
|
||||||
|
"User",
|
||||||
|
back_populates="api_keys",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<ApiKey {self.key_prefix}... ({self.name})>"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self) -> bool:
|
||||||
|
"""Check if the API key has expired"""
|
||||||
|
if self.expires_at is None:
|
||||||
|
return False
|
||||||
|
return datetime.now(self.expires_at.tzinfo) > self.expires_at
|
||||||
@@ -0,0 +1,776 @@
|
|||||||
|
"""
|
||||||
|
OIDC Authentication Module
|
||||||
|
|
||||||
|
Provides OAuth2/OIDC token validation for FastAPI using Authentik as IdP.
|
||||||
|
Implements bearer token authentication with JWT verification.
|
||||||
|
|
||||||
|
Permission Format: domain.category:action
|
||||||
|
- domain: Main area (control-room, library, media, ai, etc.)
|
||||||
|
- category: Sub-area within domain (general for full domain, or specific tools)
|
||||||
|
- action: Permission level (viewer, user, editor, admin)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- control-room.general:admin - Full access to Control Room
|
||||||
|
- media.general:viewer - View-only access to Media area
|
||||||
|
|
||||||
|
Action Hierarchy (higher implies lower):
|
||||||
|
- admin > editor > user > viewer
|
||||||
|
"""
|
||||||
|
from fastapi import Depends, HTTPException, Security, Request
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from jose import jwt, JWTError
|
||||||
|
import httpx
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Callable, Dict, List, Optional
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Permission System
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Action hierarchy: higher actions imply lower ones
|
||||||
|
ACTION_HIERARCHY: Dict[str, int] = {
|
||||||
|
"viewer": 1,
|
||||||
|
"user": 2,
|
||||||
|
"editor": 3,
|
||||||
|
"admin": 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Valid domains (main areas)
|
||||||
|
VALID_DOMAINS = {
|
||||||
|
"control-room",
|
||||||
|
"library",
|
||||||
|
"media",
|
||||||
|
"ai",
|
||||||
|
"housekeeper",
|
||||||
|
"developer",
|
||||||
|
"documents",
|
||||||
|
"gaming",
|
||||||
|
"admin", # Global admin domain
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default category for general domain access
|
||||||
|
DEFAULT_CATEGORY = "general"
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
class OIDCConfig:
|
||||||
|
"""OIDC configuration from environment"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# These will be set from environment variables in config.py
|
||||||
|
self.enabled = False
|
||||||
|
self.issuers: list[str] = []
|
||||||
|
self.audiences: list[str] = []
|
||||||
|
|
||||||
|
def configure(self, enabled: bool, issuers: list[str], audiences: list[str]):
|
||||||
|
"""Configure OIDC settings"""
|
||||||
|
self.enabled = enabled
|
||||||
|
self.issuers = [iss.rstrip('/') for iss in issuers] # Normalize without trailing slash
|
||||||
|
self.audiences = audiences
|
||||||
|
logger.info(f"OIDC configured: enabled={enabled}, issuers={self.issuers}, audiences={audiences}")
|
||||||
|
|
||||||
|
def get_jwks_uri(self, issuer: str) -> str:
|
||||||
|
"""Get JWKS URI for a specific issuer"""
|
||||||
|
return f"{issuer.rstrip('/')}/jwks/"
|
||||||
|
|
||||||
|
def is_valid_issuer(self, issuer: str) -> bool:
|
||||||
|
"""Check if issuer is in the allowed list"""
|
||||||
|
normalized = issuer.rstrip('/')
|
||||||
|
return normalized in self.issuers
|
||||||
|
|
||||||
|
|
||||||
|
# Global OIDC config instance
|
||||||
|
oidc_config = OIDCConfig()
|
||||||
|
|
||||||
|
|
||||||
|
# Per-issuer JWKS cache
|
||||||
|
_jwks_cache: Dict[str, Dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_jwks_for_issuer(issuer: str) -> Dict:
|
||||||
|
"""
|
||||||
|
Fetch JSON Web Key Set (JWKS) for a specific issuer.
|
||||||
|
|
||||||
|
Cached per-issuer to avoid repeated requests. Cache is cleared on server restart.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
issuer: The token issuer URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JWKS dictionary containing public keys for token verification
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If JWKS fetch fails
|
||||||
|
"""
|
||||||
|
if not oidc_config.enabled:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
normalized_issuer = issuer.rstrip('/')
|
||||||
|
|
||||||
|
# Return cached JWKS if available
|
||||||
|
if normalized_issuer in _jwks_cache:
|
||||||
|
return _jwks_cache[normalized_issuer]
|
||||||
|
|
||||||
|
jwks_uri = oidc_config.get_jwks_uri(normalized_issuer)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.debug(f"Fetching JWKS from {jwks_uri}")
|
||||||
|
response = httpx.get(jwks_uri, timeout=10.0)
|
||||||
|
response.raise_for_status()
|
||||||
|
jwks = response.json()
|
||||||
|
logger.info(f"JWKS fetched successfully for {normalized_issuer} ({len(jwks.get('keys', []))} keys)")
|
||||||
|
_jwks_cache[normalized_issuer] = jwks
|
||||||
|
return jwks
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to fetch JWKS from {jwks_uri}: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Authentication service unavailable"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(security)
|
||||||
|
) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
Validate OIDC token from Authorization: Bearer header
|
||||||
|
|
||||||
|
Extracts and validates JWT token from request header. Verifies:
|
||||||
|
- Token signature using JWKS
|
||||||
|
- Token expiration
|
||||||
|
- Issuer matches Authentik
|
||||||
|
- Audience matches core-api
|
||||||
|
|
||||||
|
Args:
|
||||||
|
credentials: HTTP Bearer token from Authorization header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User claims dictionary containing email, name, groups, etc.
|
||||||
|
Returns None if OIDC is disabled (allows unauthenticated access)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 401: If token is invalid, expired, or missing when OIDC enabled
|
||||||
|
"""
|
||||||
|
# If OIDC is disabled, return a default local user
|
||||||
|
if not oidc_config.enabled:
|
||||||
|
logger.debug("OIDC disabled - using local user")
|
||||||
|
return {
|
||||||
|
"sub": "local-user",
|
||||||
|
"email": "local@localhost",
|
||||||
|
"preferred_username": "local",
|
||||||
|
"name": "Local User",
|
||||||
|
"groups": ["admin"],
|
||||||
|
"auth_method": "local"
|
||||||
|
}
|
||||||
|
|
||||||
|
# OIDC enabled - token required
|
||||||
|
if not credentials:
|
||||||
|
logger.warning("Authentication required but no token provided")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Authentication required",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
token = credentials.credentials
|
||||||
|
|
||||||
|
try:
|
||||||
|
# First, decode token without verification to get issuer and key ID
|
||||||
|
unverified_header = jwt.get_unverified_header(token)
|
||||||
|
unverified_claims = jwt.get_unverified_claims(token)
|
||||||
|
|
||||||
|
kid = unverified_header.get("kid")
|
||||||
|
token_issuer = unverified_claims.get("iss", "")
|
||||||
|
token_audience = unverified_claims.get("aud", "")
|
||||||
|
|
||||||
|
if not kid:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token format")
|
||||||
|
|
||||||
|
# Validate issuer is in allowed list
|
||||||
|
logger.debug(f"Token issuer: {token_issuer}, allowed issuers: {oidc_config.issuers}")
|
||||||
|
if not oidc_config.is_valid_issuer(token_issuer):
|
||||||
|
logger.warning(f"Invalid token issuer: {token_issuer} (allowed: {oidc_config.issuers})")
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token issuer")
|
||||||
|
|
||||||
|
# Validate audience is in allowed list
|
||||||
|
if token_audience not in oidc_config.audiences:
|
||||||
|
logger.warning(f"Invalid token audience: {token_audience} (allowed: {oidc_config.audiences})")
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token audience")
|
||||||
|
|
||||||
|
# Get JWKS for this specific issuer
|
||||||
|
jwks = get_jwks_for_issuer(token_issuer)
|
||||||
|
rsa_key = None
|
||||||
|
|
||||||
|
for key in jwks.get("keys", []):
|
||||||
|
if key.get("kid") == kid:
|
||||||
|
rsa_key = key
|
||||||
|
break
|
||||||
|
|
||||||
|
if not rsa_key:
|
||||||
|
logger.warning(f"No matching key found for kid: {kid}")
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token key")
|
||||||
|
|
||||||
|
# Verify and decode token using the token's actual issuer and audience
|
||||||
|
payload = jwt.decode(
|
||||||
|
token,
|
||||||
|
rsa_key,
|
||||||
|
algorithms=["RS256"],
|
||||||
|
audience=token_audience, # Use the token's audience (already validated)
|
||||||
|
issuer=token_issuer, # Use the token's issuer (already validated)
|
||||||
|
)
|
||||||
|
|
||||||
|
user_email = payload.get("email", "unknown")
|
||||||
|
logger.info(f"Authenticated user: {user_email} (issuer: {token_issuer})")
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
logger.warning("Token expired")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Token expired",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
except jwt.JWTClaimsError as e:
|
||||||
|
logger.warning(f"Invalid token claims: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Invalid token claims",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
except JWTError as e:
|
||||||
|
logger.error(f"JWT validation error: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Invalid authentication token",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected authentication error: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="Authentication error",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_admin_user(
|
||||||
|
user: Optional[Dict] = Depends(get_current_user)
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
Require admin group membership
|
||||||
|
|
||||||
|
Use this dependency for endpoints that require admin access.
|
||||||
|
Checks if user is member of 'admin' group in Authentik.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User claims from get_current_user
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User claims dictionary if user is admin
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 403: If user is not in admin group
|
||||||
|
HTTPException 401: If OIDC enabled but user not authenticated
|
||||||
|
"""
|
||||||
|
# If OIDC disabled, allow all (backward compatibility)
|
||||||
|
if not oidc_config.enabled or user is None:
|
||||||
|
logger.debug("OIDC disabled - allowing admin access")
|
||||||
|
return {"email": "unauthenticated", "groups": ["admin"]}
|
||||||
|
|
||||||
|
# Check admin group membership
|
||||||
|
groups = user.get("groups", [])
|
||||||
|
|
||||||
|
if "admin" not in groups and "authentik Admins" not in groups:
|
||||||
|
user_email = user.get("email", "unknown")
|
||||||
|
logger.warning(f"User {user_email} attempted admin access (groups: {groups})")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Admin access required"
|
||||||
|
)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_optional_user(
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(security)
|
||||||
|
) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
Optional authentication - allows both authenticated and unauthenticated access
|
||||||
|
|
||||||
|
Use for endpoints that should be accessible to everyone but can provide
|
||||||
|
enhanced functionality for authenticated users.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
credentials: HTTP Bearer token from Authorization header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User claims if valid token provided, local user if OIDC disabled, None otherwise
|
||||||
|
"""
|
||||||
|
# If OIDC is disabled, return the local user
|
||||||
|
if not oidc_config.enabled:
|
||||||
|
return {
|
||||||
|
"sub": "local-user",
|
||||||
|
"email": "local@localhost",
|
||||||
|
"preferred_username": "local",
|
||||||
|
"name": "Local User",
|
||||||
|
"groups": ["admin"],
|
||||||
|
"auth_method": "local"
|
||||||
|
}
|
||||||
|
|
||||||
|
if not credentials:
|
||||||
|
logger.debug("No credentials provided for optional auth")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await get_current_user(credentials)
|
||||||
|
except HTTPException as e:
|
||||||
|
# Invalid token - log and return None instead of raising
|
||||||
|
logger.warning(f"Optional auth failed: {e.detail}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_forward_auth_user(
|
||||||
|
request: Request
|
||||||
|
) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
Authentik Forward Auth authentication for external access via NPM
|
||||||
|
|
||||||
|
This dependency allows:
|
||||||
|
- External access through api.schweitz.net (with Authentik forward auth headers) - REQUIRES authentication
|
||||||
|
- Internal direct access (no forward auth headers) - ALLOWED without authentication
|
||||||
|
|
||||||
|
When accessing through NPM with Authentik forward auth enabled, NPM adds headers like:
|
||||||
|
- X-authentik-username
|
||||||
|
- X-authentik-email
|
||||||
|
- X-authentik-groups
|
||||||
|
- X-authentik-name
|
||||||
|
- X-authentik-uid
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object containing headers
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User info dict if authenticated via forward auth headers
|
||||||
|
None if accessed internally (no forward auth headers)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 401: If forward auth headers present but invalid/incomplete
|
||||||
|
"""
|
||||||
|
# Check for Authentik forward auth headers
|
||||||
|
username = request.headers.get("x-authentik-username")
|
||||||
|
email = request.headers.get("x-authentik-email")
|
||||||
|
groups = request.headers.get("x-authentik-groups")
|
||||||
|
name = request.headers.get("x-authentik-name")
|
||||||
|
uid = request.headers.get("x-authentik-uid")
|
||||||
|
|
||||||
|
# If NO forward auth headers present, this is internal access - allow it
|
||||||
|
if not username and not email:
|
||||||
|
logger.debug("No forward auth headers - allowing internal access")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Forward auth headers present (external access via api.schweitz.net)
|
||||||
|
# Validate authentication
|
||||||
|
if not username or not email:
|
||||||
|
logger.warning("Incomplete forward auth headers detected")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Authentication required - incomplete forward auth headers"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse groups (comma-separated string to list)
|
||||||
|
groups_list = [g.strip() for g in groups.split(",")] if groups else []
|
||||||
|
|
||||||
|
user_info = {
|
||||||
|
"username": username,
|
||||||
|
"email": email,
|
||||||
|
"name": name or username,
|
||||||
|
"groups": groups_list,
|
||||||
|
"uid": uid,
|
||||||
|
"auth_method": "forward_auth"
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Authenticated via forward auth: {email} (groups: {groups_list})")
|
||||||
|
return user_info
|
||||||
|
|
||||||
|
|
||||||
|
async def get_forward_auth_admin(
|
||||||
|
user: Optional[Dict] = Depends(get_forward_auth_user)
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
Require admin access for external requests, allow all internal requests
|
||||||
|
|
||||||
|
Use this dependency for endpoints that require admin access when accessed
|
||||||
|
externally through api.schweitz.net, but allow unrestricted internal access.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User info from get_forward_auth_user
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User info dict if user is admin or if accessed internally
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 403: If external user is not in admin/authentik Admins group
|
||||||
|
"""
|
||||||
|
# Internal access (no forward auth headers) - allow all
|
||||||
|
if user is None:
|
||||||
|
logger.debug("Internal access - allowing without admin check")
|
||||||
|
return {"email": "internal", "groups": ["admin"], "auth_method": "internal"}
|
||||||
|
|
||||||
|
# External access - check admin group membership
|
||||||
|
groups = user.get("groups", [])
|
||||||
|
|
||||||
|
if "admin" not in groups and "authentik Admins" not in groups:
|
||||||
|
user_email = user.get("email", "unknown")
|
||||||
|
logger.warning(f"User {user_email} attempted admin access (groups: {groups})")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Admin access required"
|
||||||
|
)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user_or_forward_auth(
|
||||||
|
request: Request,
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(security)
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
Combined auth: Try forward auth headers first, then JWT Bearer token.
|
||||||
|
|
||||||
|
Supports both:
|
||||||
|
- Web clients via NPM forward auth (X-authentik-* headers from proxy)
|
||||||
|
- Mobile/native clients via OIDC JWT Bearer tokens
|
||||||
|
|
||||||
|
This is the preferred dependency for /auth/users/me and similar endpoints
|
||||||
|
that need to work with both web (cookie-based via NPM) and mobile (token-based).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object containing headers
|
||||||
|
credentials: HTTP Bearer token from Authorization header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User claims dictionary with at minimum: sub, email, name, groups, auth_method
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 401: If neither forward auth headers nor valid JWT provided
|
||||||
|
"""
|
||||||
|
# 1. Try forward auth headers first (web via NPM)
|
||||||
|
username = request.headers.get("x-authentik-username")
|
||||||
|
email = request.headers.get("x-authentik-email")
|
||||||
|
|
||||||
|
if username and email:
|
||||||
|
# Forward auth headers present - use them
|
||||||
|
groups = request.headers.get("x-authentik-groups", "")
|
||||||
|
name = request.headers.get("x-authentik-name", username)
|
||||||
|
uid = request.headers.get("x-authentik-uid")
|
||||||
|
|
||||||
|
user_info = {
|
||||||
|
"sub": uid, # Use authentik UID as subject (for user lookup)
|
||||||
|
"email": email,
|
||||||
|
"preferred_username": username,
|
||||||
|
"name": name,
|
||||||
|
"groups": [g.strip() for g in groups.split(",")] if groups else [],
|
||||||
|
"auth_method": "forward_auth"
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Authenticated via forward auth: {email}")
|
||||||
|
return user_info
|
||||||
|
|
||||||
|
# 2. Fall back to JWT Bearer token (mobile/native)
|
||||||
|
return await get_current_user(credentials)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Permission-Based Access Control
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def _parse_permission(permission: str) -> tuple[str, str, str]:
|
||||||
|
"""
|
||||||
|
Parse a permission string into (domain, category, action)
|
||||||
|
|
||||||
|
Supports formats:
|
||||||
|
- domain.category:action (full): "control-room.general:admin"
|
||||||
|
- domain:action (shorthand): "control-room:admin" -> ("control-room", "general", "admin")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (domain, category, action)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If permission format is invalid
|
||||||
|
"""
|
||||||
|
# Split on colon first to get action
|
||||||
|
if ":" not in permission:
|
||||||
|
raise ValueError(f"Invalid permission format (missing ':'): {permission}")
|
||||||
|
|
||||||
|
location, action = permission.rsplit(":", 1)
|
||||||
|
|
||||||
|
# Split location on dot to get domain and category
|
||||||
|
if "." in location:
|
||||||
|
domain, category = location.split(".", 1)
|
||||||
|
else:
|
||||||
|
# Shorthand: domain:action -> domain.general:action
|
||||||
|
domain = location
|
||||||
|
category = DEFAULT_CATEGORY
|
||||||
|
|
||||||
|
return domain, category, action
|
||||||
|
|
||||||
|
|
||||||
|
def _action_satisfies(user_action: str, required_action: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if user's action level satisfies the required action
|
||||||
|
|
||||||
|
Due to hierarchy, admin satisfies editor, editor satisfies user, etc.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_action: The action the user has
|
||||||
|
required_action: The action required for access
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if user's action is >= required action
|
||||||
|
"""
|
||||||
|
user_level = ACTION_HIERARCHY.get(user_action, 0)
|
||||||
|
required_level = ACTION_HIERARCHY.get(required_action, 0)
|
||||||
|
return user_level >= required_level
|
||||||
|
|
||||||
|
|
||||||
|
def _user_has_permission(
|
||||||
|
user_permissions: List[str],
|
||||||
|
required_domain: str,
|
||||||
|
required_category: str,
|
||||||
|
required_action: str,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Check if user has a permission that satisfies the requirement
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
1. Exact match: domain.category:action
|
||||||
|
2. Domain-wide: domain.general:action (if category != general)
|
||||||
|
3. Global admin: admin.general:admin (superuser)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_permissions: List of user's permission strings
|
||||||
|
required_domain: Required domain
|
||||||
|
required_category: Required category
|
||||||
|
required_action: Required action
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if user has sufficient permission
|
||||||
|
"""
|
||||||
|
for perm in user_permissions:
|
||||||
|
try:
|
||||||
|
dom, cat, act = _parse_permission(perm)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Global admin (admin.general:admin) grants all permissions
|
||||||
|
if dom == "admin" and cat == "general" and act == "admin":
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check if this permission covers the requirement
|
||||||
|
if dom == required_domain:
|
||||||
|
# Exact category match
|
||||||
|
if cat == required_category and _action_satisfies(act, required_action):
|
||||||
|
return True
|
||||||
|
# Domain-wide permission (general category) covers all categories in domain
|
||||||
|
if cat == "general" and _action_satisfies(act, required_action):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_permissions_from_groups(groups: List[str]) -> List[str]:
|
||||||
|
"""
|
||||||
|
Extract permission strings from Authentik group names
|
||||||
|
|
||||||
|
Authentik groups follow naming: tatlock-{domain}-{category}-{action}
|
||||||
|
or shorthand: tatlock-{domain}-{action} (implies category=general)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- tatlock-control-room-general-admin -> control-room.general:admin
|
||||||
|
- tatlock-media-viewer -> media.general:viewer (shorthand)
|
||||||
|
- tatlock-tools-dns-user -> tools.dns:user
|
||||||
|
|
||||||
|
Args:
|
||||||
|
groups: List of Authentik group names
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of permission strings
|
||||||
|
"""
|
||||||
|
permissions = []
|
||||||
|
|
||||||
|
for group in groups:
|
||||||
|
if not group.startswith("tatlock-"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Remove prefix
|
||||||
|
parts = group[8:].split("-") # Remove "tatlock-"
|
||||||
|
|
||||||
|
if len(parts) >= 3:
|
||||||
|
# Could be domain-category-action or domain-with-hyphen-action
|
||||||
|
# Try to find a valid action at the end
|
||||||
|
action = parts[-1]
|
||||||
|
if action in ACTION_HIERARCHY:
|
||||||
|
# Check if domain-category or single domain with hyphen
|
||||||
|
remaining = parts[:-1]
|
||||||
|
|
||||||
|
# Try to find known domain (greedy match from start)
|
||||||
|
for i in range(len(remaining), 0, -1):
|
||||||
|
potential_domain = "-".join(remaining[:i])
|
||||||
|
if potential_domain in VALID_DOMAINS:
|
||||||
|
category_parts = remaining[i:]
|
||||||
|
category = "-".join(category_parts) if category_parts else DEFAULT_CATEGORY
|
||||||
|
permissions.append(f"{potential_domain}.{category}:{action}")
|
||||||
|
break
|
||||||
|
elif len(parts) == 2:
|
||||||
|
# Shorthand: domain-action (domain might have hyphen)
|
||||||
|
action = parts[-1]
|
||||||
|
if action in ACTION_HIERARCHY:
|
||||||
|
domain = parts[0]
|
||||||
|
if domain in VALID_DOMAINS:
|
||||||
|
permissions.append(f"{domain}.{DEFAULT_CATEGORY}:{action}")
|
||||||
|
|
||||||
|
return permissions
|
||||||
|
|
||||||
|
|
||||||
|
def require_permission(
|
||||||
|
domain: str,
|
||||||
|
action: str,
|
||||||
|
category: str = DEFAULT_CATEGORY,
|
||||||
|
) -> Callable:
|
||||||
|
"""
|
||||||
|
Dependency factory for permission-based access control
|
||||||
|
|
||||||
|
Creates a FastAPI dependency that checks if the current user has
|
||||||
|
the required permission. Considers action hierarchy and global admin.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@router.get("/containers")
|
||||||
|
async def list_containers(
|
||||||
|
user: Dict = Depends(require_permission("control-room", "viewer"))
|
||||||
|
):
|
||||||
|
...
|
||||||
|
|
||||||
|
@router.delete("/container/{id}")
|
||||||
|
async def delete_container(
|
||||||
|
user: Dict = Depends(require_permission("control-room", "admin"))
|
||||||
|
):
|
||||||
|
...
|
||||||
|
|
||||||
|
Args:
|
||||||
|
domain: Permission domain (e.g., "control-room", "media")
|
||||||
|
action: Required action level (viewer, user, editor, admin)
|
||||||
|
category: Permission category within domain, defaults to "general"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FastAPI dependency function
|
||||||
|
"""
|
||||||
|
perm_str = f"{domain}.{category}:{action}"
|
||||||
|
|
||||||
|
async def permission_checker(
|
||||||
|
user: Optional[Dict] = Depends(get_current_user)
|
||||||
|
) -> Dict:
|
||||||
|
"""Check if user has required permission"""
|
||||||
|
|
||||||
|
# If OIDC disabled, allow all (local dev mode)
|
||||||
|
if not oidc_config.enabled:
|
||||||
|
logger.debug(f"OIDC disabled - allowing {perm_str}")
|
||||||
|
return user or {"email": "local", "groups": ["admin"]}
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Authentication required",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract permissions from user's groups
|
||||||
|
groups = user.get("groups", [])
|
||||||
|
permissions = _extract_permissions_from_groups(groups)
|
||||||
|
|
||||||
|
# Check if user has required permission
|
||||||
|
if _user_has_permission(permissions, domain, category, action):
|
||||||
|
logger.debug(f"User {user.get('email')} granted {perm_str}")
|
||||||
|
return user
|
||||||
|
|
||||||
|
# Permission denied
|
||||||
|
user_email = user.get("email", "unknown")
|
||||||
|
logger.warning(
|
||||||
|
f"User {user_email} denied {perm_str} "
|
||||||
|
f"(groups: {groups}, permissions: {permissions})"
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=f"Permission required: {perm_str}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return permission_checker
|
||||||
|
|
||||||
|
|
||||||
|
def require_any_permission(*required_permissions: str) -> Callable:
|
||||||
|
"""
|
||||||
|
Dependency factory requiring any one of multiple permissions
|
||||||
|
|
||||||
|
Useful for endpoints accessible to multiple roles.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@router.get("/shared-resource")
|
||||||
|
async def get_shared(
|
||||||
|
user: Dict = Depends(require_any_permission(
|
||||||
|
"control-room:viewer",
|
||||||
|
"media:viewer",
|
||||||
|
))
|
||||||
|
):
|
||||||
|
...
|
||||||
|
|
||||||
|
Args:
|
||||||
|
*required_permissions: Permission strings (domain.category:action or domain:action)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FastAPI dependency function
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def permission_checker(
|
||||||
|
user: Optional[Dict] = Depends(get_current_user)
|
||||||
|
) -> Dict:
|
||||||
|
"""Check if user has any of the required permissions"""
|
||||||
|
|
||||||
|
# If OIDC disabled, allow all
|
||||||
|
if not oidc_config.enabled:
|
||||||
|
return user or {"email": "local", "groups": ["admin"]}
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Authentication required",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
groups = user.get("groups", [])
|
||||||
|
permissions = _extract_permissions_from_groups(groups)
|
||||||
|
|
||||||
|
# Check each required permission
|
||||||
|
for perm in required_permissions:
|
||||||
|
try:
|
||||||
|
dom, cat, act = _parse_permission(perm)
|
||||||
|
if _user_has_permission(permissions, dom, cat, act):
|
||||||
|
logger.debug(f"User {user.get('email')} granted via {perm}")
|
||||||
|
return user
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(f"Invalid permission format: {perm}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# None matched
|
||||||
|
user_email = user.get("email", "unknown")
|
||||||
|
logger.warning(
|
||||||
|
f"User {user_email} denied (required any of: {required_permissions})"
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=f"One of these permissions required: {', '.join(required_permissions)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return permission_checker
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""
|
||||||
|
Authentication Schemas
|
||||||
|
|
||||||
|
Pydantic models for auth request/response payloads.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from src.shared.base import BaseSchema
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSyncRequest(BaseSchema):
|
||||||
|
"""
|
||||||
|
Request payload for POST /auth/sync
|
||||||
|
|
||||||
|
The client sends this after obtaining an OIDC token from Authentik.
|
||||||
|
The access_token is validated against Authentik's userinfo endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
access_token: str = Field(
|
||||||
|
...,
|
||||||
|
description="OIDC access token from Authentik",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RoleSchema(BaseSchema):
|
||||||
|
"""Role information in domain.category:action format"""
|
||||||
|
|
||||||
|
id: uuid.UUID = Field(..., description="Role ID")
|
||||||
|
name: str = Field(..., description="Role name (e.g., 'control-room.general:admin')")
|
||||||
|
domain: str = Field(..., description="Permission domain (e.g., 'control-room')")
|
||||||
|
category: str = Field(default="general", description="Permission category (e.g., 'general')")
|
||||||
|
action: str = Field(..., description="Permission action (e.g., 'admin')")
|
||||||
|
|
||||||
|
|
||||||
|
class UserPreferencesSchema(BaseSchema):
|
||||||
|
"""User preferences"""
|
||||||
|
|
||||||
|
theme: str = Field(default="system", description="Theme preference: system, light, dark")
|
||||||
|
default_room: str = Field(default="front-hall", description="Default room for housekeeping")
|
||||||
|
preferences_json: dict = Field(default_factory=dict, description="Extended preferences")
|
||||||
|
|
||||||
|
|
||||||
|
class UserSchema(BaseSchema):
|
||||||
|
"""User information returned from sync"""
|
||||||
|
|
||||||
|
id: uuid.UUID = Field(..., description="Internal user ID")
|
||||||
|
authentik_id: uuid.UUID = Field(..., description="Authentik user ID")
|
||||||
|
email: str = Field(..., description="User email")
|
||||||
|
name: str = Field(..., description="Display name")
|
||||||
|
avatar_url: Optional[str] = Field(None, description="Profile picture URL")
|
||||||
|
created_at: datetime = Field(..., description="Account creation timestamp")
|
||||||
|
last_login: Optional[datetime] = Field(None, description="Last login timestamp")
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSyncResponse(BaseSchema):
|
||||||
|
"""
|
||||||
|
Response from POST /auth/sync
|
||||||
|
|
||||||
|
Contains the synced user profile, roles, and preferences.
|
||||||
|
"""
|
||||||
|
|
||||||
|
user: UserSchema = Field(..., description="User profile")
|
||||||
|
roles: list[RoleSchema] = Field(..., description="User's permission roles")
|
||||||
|
preferences: UserPreferencesSchema = Field(..., description="User preferences")
|
||||||
|
is_new_user: bool = Field(..., description="True if user was just created")
|
||||||
|
|
||||||
|
|
||||||
|
class TokenInfoSchema(BaseSchema):
|
||||||
|
"""
|
||||||
|
Token information from Authentik userinfo endpoint
|
||||||
|
|
||||||
|
This is what Authentik returns when validating an access token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
sub: str = Field(..., description="Subject (Authentik user ID)")
|
||||||
|
email: str = Field(..., description="User email")
|
||||||
|
name: Optional[str] = Field(None, description="Display name")
|
||||||
|
preferred_username: Optional[str] = Field(None, description="Username")
|
||||||
|
groups: list[str] = Field(default_factory=list, description="Group memberships")
|
||||||
|
picture: Optional[str] = Field(None, description="Profile picture URL")
|
||||||
|
|
||||||
|
|
||||||
|
class UserListItemSchema(BaseSchema):
|
||||||
|
"""User item for list display"""
|
||||||
|
|
||||||
|
id: uuid.UUID = Field(..., description="Internal user ID")
|
||||||
|
email: str = Field(..., description="User email")
|
||||||
|
name: str = Field(..., description="Display name")
|
||||||
|
avatar_url: Optional[str] = Field(None, description="Profile picture URL")
|
||||||
|
created_at: datetime = Field(..., description="Account creation timestamp")
|
||||||
|
last_login: Optional[datetime] = Field(None, description="Last login timestamp")
|
||||||
|
roles: list[str] = Field(default_factory=list, description="Role names")
|
||||||
|
|
||||||
|
|
||||||
|
class UsersListResponse(BaseSchema):
|
||||||
|
"""Response from GET /auth/users"""
|
||||||
|
|
||||||
|
items: list[UserListItemSchema] = Field(..., description="List of users")
|
||||||
|
total: int = Field(..., description="Total count of users")
|
||||||
|
|
||||||
|
|
||||||
|
class BulkSyncResultSchema(BaseSchema):
|
||||||
|
"""Result from bulk sync operation"""
|
||||||
|
|
||||||
|
created: int = Field(..., description="Number of users created")
|
||||||
|
updated: int = Field(..., description="Number of users updated")
|
||||||
|
failed: int = Field(..., description="Number of users that failed to sync")
|
||||||
|
total_in_authentik: int = Field(..., description="Total users in Authentik")
|
||||||
|
errors: list[str] = Field(default_factory=list, description="Error messages for failed syncs")
|
||||||
|
|
||||||
|
|
||||||
|
class GroupListItemSchema(BaseSchema):
|
||||||
|
"""Group item for list display"""
|
||||||
|
|
||||||
|
id: uuid.UUID = Field(..., description="Internal group ID")
|
||||||
|
authentik_id: uuid.UUID = Field(..., description="Authentik group ID")
|
||||||
|
name: str = Field(..., description="Group name")
|
||||||
|
is_superuser: bool = Field(default=False, description="Whether group has superuser privileges")
|
||||||
|
parent_name: Optional[str] = Field(None, description="Parent group name")
|
||||||
|
member_count: int = Field(default=0, description="Number of users in this group")
|
||||||
|
synced_at: datetime = Field(..., description="Last sync timestamp")
|
||||||
|
roles: list[str] = Field(default_factory=list, description="Assigned role names")
|
||||||
|
|
||||||
|
|
||||||
|
class GroupsListResponse(BaseSchema):
|
||||||
|
"""Response from GET /auth/groups"""
|
||||||
|
|
||||||
|
items: list[GroupListItemSchema] = Field(..., description="List of groups")
|
||||||
|
total: int = Field(..., description="Total count of groups")
|
||||||
|
|
||||||
|
|
||||||
|
class RolesListResponse(BaseSchema):
|
||||||
|
"""Response from GET /auth/roles"""
|
||||||
|
|
||||||
|
items: list[RoleSchema] = Field(..., description="List of all roles")
|
||||||
|
total: int = Field(..., description="Total count of roles")
|
||||||
|
|
||||||
|
|
||||||
|
class GroupRoleAssignmentResponse(BaseSchema):
|
||||||
|
"""Response from group role assignment operations"""
|
||||||
|
|
||||||
|
group_id: uuid.UUID = Field(..., description="Group ID")
|
||||||
|
group_name: str = Field(..., description="Group name")
|
||||||
|
roles: list[str] = Field(..., description="Currently assigned role names")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# User Profile (Phase 4)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class UserProfileResponse(BaseSchema):
|
||||||
|
"""Response from GET /users/me - full user profile"""
|
||||||
|
|
||||||
|
user: UserSchema = Field(..., description="User profile")
|
||||||
|
roles: list[RoleSchema] = Field(..., description="User's permission roles")
|
||||||
|
preferences: UserPreferencesSchema = Field(..., description="User preferences")
|
||||||
|
|
||||||
|
|
||||||
|
class PreferencesUpdateRequest(BaseSchema):
|
||||||
|
"""Request for PATCH /users/me/preferences"""
|
||||||
|
|
||||||
|
theme: Optional[str] = Field(None, description="Theme preference: system, light, dark")
|
||||||
|
default_room: Optional[str] = Field(None, description="Default room for housekeeping")
|
||||||
|
preferences_json: Optional[dict] = Field(None, description="Extended preferences (merged)")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# API Keys (Phase 4)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class ApiKeyCreateRequest(BaseSchema):
|
||||||
|
"""Request for POST /users/me/api-keys"""
|
||||||
|
|
||||||
|
name: str = Field(..., min_length=1, max_length=100, description="Human-readable key name")
|
||||||
|
scopes: Optional[list[str]] = Field(None, description="Optional scope restriction (role names)")
|
||||||
|
expires_in_days: Optional[int] = Field(None, ge=1, le=365, description="Days until expiration (optional)")
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeyCreateResponse(BaseSchema):
|
||||||
|
"""Response from POST /users/me/api-keys - includes the key (shown only once)"""
|
||||||
|
|
||||||
|
id: uuid.UUID = Field(..., description="API key ID")
|
||||||
|
name: str = Field(..., description="Key name")
|
||||||
|
key: str = Field(..., description="The API key (shown only once!)")
|
||||||
|
key_prefix: str = Field(..., description="Key prefix for identification")
|
||||||
|
scopes: Optional[list[str]] = Field(None, description="Scope restriction")
|
||||||
|
expires_at: Optional[datetime] = Field(None, description="Expiration timestamp")
|
||||||
|
created_at: datetime = Field(..., description="Creation timestamp")
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeySchema(BaseSchema):
|
||||||
|
"""API key information (without the actual key)"""
|
||||||
|
|
||||||
|
id: uuid.UUID = Field(..., description="API key ID")
|
||||||
|
name: str = Field(..., description="Key name")
|
||||||
|
key_prefix: str = Field(..., description="Key prefix for identification (e.g., 'tak_abc1')")
|
||||||
|
scopes: Optional[list[str]] = Field(None, description="Scope restriction")
|
||||||
|
expires_at: Optional[datetime] = Field(None, description="Expiration timestamp")
|
||||||
|
last_used_at: Optional[datetime] = Field(None, description="Last usage timestamp")
|
||||||
|
created_at: datetime = Field(..., description="Creation timestamp")
|
||||||
|
is_expired: bool = Field(..., description="Whether the key has expired")
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeysListResponse(BaseSchema):
|
||||||
|
"""Response from GET /users/me/api-keys"""
|
||||||
|
|
||||||
|
items: list[ApiKeySchema] = Field(..., description="List of API keys")
|
||||||
|
total: int = Field(..., description="Total count of keys")
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
|||||||
|
"""
|
||||||
|
Book Domain
|
||||||
|
|
||||||
|
The household's Notes, Tasks, Decisions, Questions and Rejected options
|
||||||
|
(workspace D-68, D-69, D-70). Served under /book/*.
|
||||||
|
"""
|
||||||
|
from src.domains.book.controller import book_controller
|
||||||
|
|
||||||
|
__all__ = ["book_controller"]
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
"""
|
||||||
|
Book Controller
|
||||||
|
|
||||||
|
The core Book verbs (D-70): add, show, list, find, change, close,
|
||||||
|
link/unlink, tag/untag, history. Every route requires a caller (office or
|
||||||
|
user); writes additionally require an identity (T-1). Every route also
|
||||||
|
requires a tenant on the caller — enforced in the service layer
|
||||||
|
(`BookService._require_tenant`) rather than here, so reads and writes share
|
||||||
|
one check (core-api T-7).
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
|
from src.domains.auth.callers import Caller, require_caller
|
||||||
|
from src.domains.book.schemas import (
|
||||||
|
BookCloseRequest,
|
||||||
|
BookEventResponse,
|
||||||
|
BookHistoryResponse,
|
||||||
|
BookLinkRequest,
|
||||||
|
BookLinkResponse,
|
||||||
|
BookRecordCreate,
|
||||||
|
BookRecordDetailResponse,
|
||||||
|
BookRecordListResponse,
|
||||||
|
BookRecordResponse,
|
||||||
|
BookRecordUpdate,
|
||||||
|
BookTagRequest,
|
||||||
|
ListView,
|
||||||
|
)
|
||||||
|
from src.domains.book.service import BookError, get_book_service
|
||||||
|
from src.shared.base import BaseController
|
||||||
|
from src.shared.database import get_async_session
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
require_book_reader = require_caller("office", "user")
|
||||||
|
require_book_writer = require_caller("office", "user", identity_required=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_response(record, tags) -> BookRecordResponse:
|
||||||
|
schema = BookRecordResponse.model_validate(record, from_attributes=True)
|
||||||
|
return schema.model_copy(update={"tags": tags})
|
||||||
|
|
||||||
|
|
||||||
|
def _link_response(link) -> BookLinkResponse:
|
||||||
|
return BookLinkResponse.model_validate(link, from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _detail_response(record, tags, links, backlinks) -> BookRecordDetailResponse:
|
||||||
|
base = BookRecordResponse.model_validate(record, from_attributes=True).model_dump()
|
||||||
|
base["tags"] = tags
|
||||||
|
return BookRecordDetailResponse(
|
||||||
|
**base,
|
||||||
|
links=[_link_response(link) for link in links],
|
||||||
|
backlinks=[_link_response(link) for link in backlinks],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _event_response(event) -> BookEventResponse:
|
||||||
|
return BookEventResponse.model_validate(event, from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class BookController(BaseController):
|
||||||
|
"""Controller for the Book domain."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(prefix="/book", tags=["Book"])
|
||||||
|
|
||||||
|
def create_router(self) -> APIRouter:
|
||||||
|
router = APIRouter(prefix=self.prefix, tags=self.tags)
|
||||||
|
service = get_book_service()
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# add
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/records",
|
||||||
|
response_model=BookRecordResponse,
|
||||||
|
status_code=201,
|
||||||
|
summary="Add a record to the Book",
|
||||||
|
)
|
||||||
|
async def add_record(
|
||||||
|
data: BookRecordCreate,
|
||||||
|
caller: Caller = Depends(require_book_writer),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
record, tags = await service.add_record(session, data, caller)
|
||||||
|
except BookError as error:
|
||||||
|
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||||
|
return _record_response(record, tags)
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# list
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/records",
|
||||||
|
response_model=BookRecordListResponse,
|
||||||
|
summary="List records",
|
||||||
|
)
|
||||||
|
async def list_records(
|
||||||
|
view: Optional[ListView] = Query(None, description="inbox, today, upcoming, waiting, someday, pinned, archive, trash, done"),
|
||||||
|
tag: Optional[str] = Query(None),
|
||||||
|
scope: Optional[str] = Query(None),
|
||||||
|
parent: Optional[uuid.UUID] = Query(None),
|
||||||
|
number: Optional[int] = Query(None, description="find the one record with this outward id"),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
caller: Caller = Depends(require_book_reader),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
results, total = await service.list_records(
|
||||||
|
session, caller, view=view, tag=tag, scope=scope, parent=parent,
|
||||||
|
number=number, limit=limit, offset=offset,
|
||||||
|
)
|
||||||
|
except BookError as error:
|
||||||
|
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||||
|
return BookRecordListResponse(
|
||||||
|
records=[_record_response(record, tags) for record, tags in results],
|
||||||
|
total=total,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# find — declared before /{record_id} so "find" is never parsed as an id
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/records/find",
|
||||||
|
response_model=BookRecordListResponse,
|
||||||
|
summary="Full-text search the Book",
|
||||||
|
)
|
||||||
|
async def find_records(
|
||||||
|
q: str = Query(..., min_length=1),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
caller: Caller = Depends(require_book_reader),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
results, total = await service.find_records(session, caller, q=q, limit=limit, offset=offset)
|
||||||
|
except BookError as error:
|
||||||
|
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||||
|
return BookRecordListResponse(
|
||||||
|
records=[_record_response(record, tags) for record, tags in results],
|
||||||
|
total=total,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# show
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/records/{record_id}",
|
||||||
|
response_model=BookRecordDetailResponse,
|
||||||
|
summary="Show a record, its links and its tags",
|
||||||
|
)
|
||||||
|
async def show_record(
|
||||||
|
record_id: uuid.UUID,
|
||||||
|
caller: Caller = Depends(require_book_reader),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
record, tags, links, backlinks = await service.show_record(session, record_id, caller)
|
||||||
|
except BookError as error:
|
||||||
|
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||||
|
return _detail_response(record, tags, links, backlinks)
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# change
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/records/{record_id}",
|
||||||
|
response_model=BookRecordResponse,
|
||||||
|
summary="Change a record",
|
||||||
|
)
|
||||||
|
async def change_record(
|
||||||
|
record_id: uuid.UUID,
|
||||||
|
data: BookRecordUpdate,
|
||||||
|
caller: Caller = Depends(require_book_writer),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
record, tags = await service.change_record(session, record_id, data, caller)
|
||||||
|
except BookError as error:
|
||||||
|
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||||
|
return _record_response(record, tags)
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# close
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/records/{record_id}/close",
|
||||||
|
response_model=BookRecordResponse,
|
||||||
|
summary="Close a record (task done/dropped, question resolved, decision superseded)",
|
||||||
|
)
|
||||||
|
async def close_record(
|
||||||
|
record_id: uuid.UUID,
|
||||||
|
data: BookCloseRequest,
|
||||||
|
caller: Caller = Depends(require_book_writer),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
record, tags = await service.close_record(session, record_id, data, caller)
|
||||||
|
except BookError as error:
|
||||||
|
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||||
|
return _record_response(record, tags)
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# link / unlink
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/records/{record_id}/link",
|
||||||
|
response_model=BookRecordResponse,
|
||||||
|
summary="Link a record to another",
|
||||||
|
)
|
||||||
|
async def link_record(
|
||||||
|
record_id: uuid.UUID,
|
||||||
|
data: BookLinkRequest,
|
||||||
|
caller: Caller = Depends(require_book_writer),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
record, tags = await service.link_records(
|
||||||
|
session, record_id, data.to_id, data.relation, caller
|
||||||
|
)
|
||||||
|
except BookError as error:
|
||||||
|
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||||
|
return _record_response(record, tags)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/records/{record_id}/unlink",
|
||||||
|
response_model=BookRecordResponse,
|
||||||
|
summary="Remove a link between two records",
|
||||||
|
)
|
||||||
|
async def unlink_record(
|
||||||
|
record_id: uuid.UUID,
|
||||||
|
data: BookLinkRequest,
|
||||||
|
caller: Caller = Depends(require_book_writer),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
record, tags = await service.unlink_records(
|
||||||
|
session, record_id, data.to_id, data.relation, caller
|
||||||
|
)
|
||||||
|
except BookError as error:
|
||||||
|
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||||
|
return _record_response(record, tags)
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# tag / untag
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/records/{record_id}/tag",
|
||||||
|
response_model=BookRecordResponse,
|
||||||
|
summary="Tag a record",
|
||||||
|
)
|
||||||
|
async def tag_record(
|
||||||
|
record_id: uuid.UUID,
|
||||||
|
data: BookTagRequest,
|
||||||
|
caller: Caller = Depends(require_book_writer),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
record, tags = await service.tag_record(session, record_id, data.tag, caller)
|
||||||
|
except BookError as error:
|
||||||
|
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||||
|
return _record_response(record, tags)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/records/{record_id}/untag",
|
||||||
|
response_model=BookRecordResponse,
|
||||||
|
summary="Remove a tag from a record",
|
||||||
|
)
|
||||||
|
async def untag_record(
|
||||||
|
record_id: uuid.UUID,
|
||||||
|
data: BookTagRequest,
|
||||||
|
caller: Caller = Depends(require_book_writer),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
record, tags = await service.untag_record(session, record_id, data.tag, caller)
|
||||||
|
except BookError as error:
|
||||||
|
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||||
|
return _record_response(record, tags)
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# history
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/records/{record_id}/history",
|
||||||
|
response_model=BookHistoryResponse,
|
||||||
|
summary="The append-only event history for a record",
|
||||||
|
)
|
||||||
|
async def record_history(
|
||||||
|
record_id: uuid.UUID,
|
||||||
|
caller: Caller = Depends(require_book_reader),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
events = await service.get_history(session, record_id, caller)
|
||||||
|
except BookError as error:
|
||||||
|
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||||
|
return BookHistoryResponse(
|
||||||
|
record_id=record_id,
|
||||||
|
events=[_event_response(event) for event in events],
|
||||||
|
)
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
# Create controller instance
|
||||||
|
book_controller = BookController()
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
"""
|
||||||
|
Book Domain Models
|
||||||
|
|
||||||
|
The Book holds Notes, Tasks, Decisions, Questions and Rejected options for
|
||||||
|
the Office (workspace D-68, D-69, D-70). SQLAlchemy 2.0 Mapped/mapped_column
|
||||||
|
style, timestamptz, following src/domains/auth/models.py.
|
||||||
|
|
||||||
|
Table prefix is `book_` (D-68). `book_events` is append-only — migration 005
|
||||||
|
adds a trigger that raises on UPDATE and DELETE, enforced at the database,
|
||||||
|
not here.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
Computed,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR, UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from src.shared.database import Base
|
||||||
|
|
||||||
|
# Kinds and their valid statuses. Enforced in the service layer (per kind),
|
||||||
|
# not as a database CHECK — the valid status set depends on the kind, which
|
||||||
|
# a single-column CHECK cannot express without a trigger.
|
||||||
|
RECORD_KINDS = ("note", "task", "decision", "question", "rejected")
|
||||||
|
|
||||||
|
STATUSES_BY_KIND = {
|
||||||
|
"note": {"active"},
|
||||||
|
"task": {"inbox", "open", "waiting", "someday", "done", "dropped"},
|
||||||
|
"decision": {"proposed", "accepted", "superseded"},
|
||||||
|
"question": {"open", "settled", "dropped"},
|
||||||
|
"rejected": {"rejected"},
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_STATUS_BY_KIND = {
|
||||||
|
"note": "active",
|
||||||
|
"task": "inbox",
|
||||||
|
"decision": "proposed",
|
||||||
|
"question": "open",
|
||||||
|
"rejected": "rejected",
|
||||||
|
}
|
||||||
|
|
||||||
|
SCOPES = ("work", "personal")
|
||||||
|
DEFAULT_SCOPE = "personal"
|
||||||
|
|
||||||
|
LINK_RELATIONS = ("parent", "blocks", "resolves", "supersedes", "related")
|
||||||
|
|
||||||
|
|
||||||
|
class BookRecord(Base):
|
||||||
|
"""A Note, Task, Decision, Question or Rejected option."""
|
||||||
|
|
||||||
|
__tablename__ = "book_records"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("source", "external_id", name="uq_book_records_source_external_id"),
|
||||||
|
UniqueConstraint("owner_tenant", "number", name="uq_book_records_tenant_number"),
|
||||||
|
Index("ix_book_records_tenant_kind_status", "owner_tenant", "kind", "status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
# The tatlock tenant the record belongs to (Caller.tenant, T-4/D-73).
|
||||||
|
# Every query in the service layer filters on it — there is no
|
||||||
|
# database-level RLS here, so that filter is load-bearing in Python,
|
||||||
|
# following src/domains/scribe/models.py's same note. FK to
|
||||||
|
# tenants.id added by migration 007 (core-api T-8).
|
||||||
|
owner_tenant: Mapped[str] = mapped_column(
|
||||||
|
String(63), ForeignKey("tenants.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
# A short outward id, numbered per tenant (never globally) so a tenant
|
||||||
|
# can never infer another's activity from its numbers. Allocated by
|
||||||
|
# BookService._allocate_number, which locks a per-tenant counter row
|
||||||
|
# FOR UPDATE in the same transaction as this row's insert.
|
||||||
|
number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
kind: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||||
|
title: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
body: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
|
checklist: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||||
|
scope: Mapped[str] = mapped_column(String(10), nullable=False, default=DEFAULT_SCOPE)
|
||||||
|
priority: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
energy: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
|
||||||
|
estimate_minutes: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
next_step: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
start_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
due_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
all_day: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
remind_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
recurrence: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
recurrence_mode: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||||
|
remind_to: Mapped[Optional[str]] = mapped_column(String(63), nullable=True)
|
||||||
|
last_touched_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
archived_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
trashed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
paused_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
paused_statuses: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||||
|
source: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||||
|
external_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
colour: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||||
|
created_by: Mapped[str] = mapped_column(String(63), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
# Generated by migration 005 (`to_tsvector('simple', ...)`, GIN-indexed).
|
||||||
|
# 'simple' because the household writes mixed Dutch and English — a
|
||||||
|
# language-specific config would stem one of the two badly. `Computed`
|
||||||
|
# mirrors the migration's SQL so SQLAlchemy knows this column is
|
||||||
|
# database-generated and excludes it from INSERT/UPDATE — without it,
|
||||||
|
# the ORM tries to write NULL into a GENERATED ALWAYS column and
|
||||||
|
# Postgres rejects the insert outright.
|
||||||
|
search: Mapped[Optional[str]] = mapped_column(
|
||||||
|
TSVECTOR,
|
||||||
|
Computed("to_tsvector('simple', coalesce(title, '') || ' ' || body)", persisted=True),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Deliberately no `tags` relationship here. The service always queries
|
||||||
|
# book_tags explicitly (`BookService._tags_for`) rather than traversing
|
||||||
|
# an ORM relationship — partly because async SQLAlchemy can't lazy-load
|
||||||
|
# on an unloaded attribute without a greenlet error, but mainly because
|
||||||
|
# a relationship named `tags` collides with the response schema's
|
||||||
|
# `tags: List[str]` field: `BookRecordResponse.model_validate(record,
|
||||||
|
# from_attributes=True)` would read the relationship (a list of BookTag
|
||||||
|
# ORM objects) instead of the list of strings the controller builds
|
||||||
|
# separately, and fail validation. Cascade delete is handled by the
|
||||||
|
# column's own `ondelete="CASCADE"`, so the relationship bought nothing.
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<BookRecord {self.kind} {self.id}>"
|
||||||
|
|
||||||
|
|
||||||
|
class BookTag(Base):
|
||||||
|
"""A free tag on a record. `work`/`personal` live on `scope`, not here."""
|
||||||
|
|
||||||
|
__tablename__ = "book_tags"
|
||||||
|
|
||||||
|
record_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("book_records.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
tag: Mapped[str] = mapped_column(String(63), primary_key=True)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<BookTag {self.tag} on {self.record_id}>"
|
||||||
|
|
||||||
|
|
||||||
|
class BookRecordLink(Base):
|
||||||
|
"""A directed relation between two records."""
|
||||||
|
|
||||||
|
__tablename__ = "book_record_links"
|
||||||
|
|
||||||
|
from_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("book_records.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
relation: Mapped[str] = mapped_column(String(20), primary_key=True)
|
||||||
|
to_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("book_records.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
created_by: Mapped[str] = mapped_column(String(63), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<BookRecordLink {self.from_id} {self.relation} {self.to_id}>"
|
||||||
|
|
||||||
|
|
||||||
|
class BookAttachment(Base):
|
||||||
|
"""A file attached to a record. Table only — no file handling yet."""
|
||||||
|
|
||||||
|
__tablename__ = "book_attachments"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
record_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("book_records.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
path: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||||
|
mime: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
size: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<BookAttachment {self.path} on {self.record_id}>"
|
||||||
|
|
||||||
|
|
||||||
|
class BookEvent(Base):
|
||||||
|
"""
|
||||||
|
An append-only audit row for every change to a record.
|
||||||
|
|
||||||
|
Migration 005 adds a trigger that raises on UPDATE and DELETE against
|
||||||
|
this table — enforced for every role, the owning application included.
|
||||||
|
`redact` (D-70 rule 4) is the one sanctioned exception, and it is a
|
||||||
|
later round; nothing here weakens the trigger.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "book_events"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
record_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("book_records.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
identity: Mapped[str] = mapped_column(String(63), nullable=False)
|
||||||
|
client: Mapped[Optional[str]] = mapped_column(String(63), nullable=True)
|
||||||
|
action: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
diff: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<BookEvent {self.action} on {self.record_id} by {self.identity}>"
|
||||||
|
|
||||||
|
|
||||||
|
class BookTenantCounter(Base):
|
||||||
|
"""
|
||||||
|
One row per tenant: the next `number` to allocate on `book_records`.
|
||||||
|
|
||||||
|
`BookService._allocate_number` upserts this row (`INSERT ... ON
|
||||||
|
CONFLICT DO NOTHING`, so a tenant's first `add` never races its own
|
||||||
|
counter row into existence) and then locks it `FOR UPDATE` in the same
|
||||||
|
transaction as the record insert it numbers — a second concurrent `add`
|
||||||
|
in the same tenant blocks on that lock until the first commits, so two
|
||||||
|
concurrent creates never read the same `next_number`. A different
|
||||||
|
tenant's counter row is untouched, so tenants never contend with each
|
||||||
|
other.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "book_tenant_counters"
|
||||||
|
|
||||||
|
# FK to tenants.id added by migration 007 (core-api T-8).
|
||||||
|
owner_tenant: Mapped[str] = mapped_column(
|
||||||
|
String(63), ForeignKey("tenants.id", ondelete="RESTRICT"), primary_key=True
|
||||||
|
)
|
||||||
|
next_number: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<BookTenantCounter {self.owner_tenant} next={self.next_number}>"
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""
|
||||||
|
Book Domain Schemas
|
||||||
|
|
||||||
|
Pydantic request/response payloads for the Book (D-68, D-70). This round
|
||||||
|
ships `add`, `show`, `list`, `find`, `change`, `close`, `link`/`unlink`,
|
||||||
|
`tag`/`untag`, `history` — the verbs the Office needs next round are not
|
||||||
|
schematized here (dossier: append, remind, reschedule, undo, purge, redact,
|
||||||
|
pin, check, snooze, archive/trash/restore, pause/resume, due, next, batch,
|
||||||
|
idempotency keys).
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, List, Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from src.shared.base import BaseSchema
|
||||||
|
|
||||||
|
RecordKind = Literal["note", "task", "decision", "question", "rejected"]
|
||||||
|
RecordScope = Literal["work", "personal"]
|
||||||
|
RecordEnergy = Literal["low", "medium", "high"]
|
||||||
|
RecurrenceMode = Literal["schedule", "after_completion"]
|
||||||
|
LinkRelation = Literal["parent", "blocks", "resolves", "supersedes", "related"]
|
||||||
|
ListView = Literal[
|
||||||
|
"inbox", "today", "upcoming", "waiting", "someday",
|
||||||
|
"pinned", "archive", "trash", "done",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class BookChecklistItem(BaseSchema):
|
||||||
|
"""One line of a record's checklist."""
|
||||||
|
|
||||||
|
text: str
|
||||||
|
checked: bool = False
|
||||||
|
position: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class BookRecordCreate(BaseSchema):
|
||||||
|
"""`add` — kind defaults to a Note so text-only capture costs nothing (D-72)."""
|
||||||
|
|
||||||
|
kind: RecordKind = "note"
|
||||||
|
title: Optional[str] = Field(None, max_length=500)
|
||||||
|
body: str = ""
|
||||||
|
checklist: List[BookChecklistItem] = Field(default_factory=list)
|
||||||
|
status: Optional[str] = None
|
||||||
|
scope: RecordScope = "personal"
|
||||||
|
priority: Optional[int] = None
|
||||||
|
pinned: bool = False
|
||||||
|
energy: Optional[RecordEnergy] = None
|
||||||
|
estimate_minutes: Optional[int] = None
|
||||||
|
next_step: Optional[str] = None
|
||||||
|
start_at: Optional[datetime] = None
|
||||||
|
due_at: Optional[datetime] = None
|
||||||
|
all_day: bool = False
|
||||||
|
remind_at: Optional[datetime] = None
|
||||||
|
recurrence: Optional[str] = None
|
||||||
|
recurrence_mode: Optional[RecurrenceMode] = None
|
||||||
|
remind_to: Optional[str] = None
|
||||||
|
source: Optional[str] = None
|
||||||
|
external_id: Optional[str] = None
|
||||||
|
colour: Optional[str] = None
|
||||||
|
tags: List[str] = Field(default_factory=list)
|
||||||
|
parent: Optional[uuid.UUID] = Field(
|
||||||
|
None, description="Convenience: also create a `parent` link to this record"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BookRecordUpdate(BaseSchema):
|
||||||
|
"""`change` — every field optional; only what is set is applied."""
|
||||||
|
|
||||||
|
kind: Optional[RecordKind] = None
|
||||||
|
title: Optional[str] = Field(None, max_length=500)
|
||||||
|
body: Optional[str] = None
|
||||||
|
checklist: Optional[List[BookChecklistItem]] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
scope: Optional[RecordScope] = None
|
||||||
|
priority: Optional[int] = None
|
||||||
|
pinned: Optional[bool] = None
|
||||||
|
energy: Optional[RecordEnergy] = None
|
||||||
|
estimate_minutes: Optional[int] = None
|
||||||
|
next_step: Optional[str] = None
|
||||||
|
start_at: Optional[datetime] = None
|
||||||
|
due_at: Optional[datetime] = None
|
||||||
|
all_day: Optional[bool] = None
|
||||||
|
remind_at: Optional[datetime] = None
|
||||||
|
recurrence: Optional[str] = None
|
||||||
|
recurrence_mode: Optional[RecurrenceMode] = None
|
||||||
|
remind_to: Optional[str] = None
|
||||||
|
source: Optional[str] = None
|
||||||
|
external_id: Optional[str] = None
|
||||||
|
colour: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BookCloseRequest(BaseSchema):
|
||||||
|
"""
|
||||||
|
`close` — the field that applies depends on the record's kind:
|
||||||
|
- task: `outcome` ("done" or "dropped")
|
||||||
|
- question: `resolved_by` (the Decision id that resolves it); sets `settled`
|
||||||
|
- decision: `superseded_by` (the new Decision id); the old one becomes `superseded`
|
||||||
|
"""
|
||||||
|
|
||||||
|
outcome: Optional[Literal["done", "dropped"]] = None
|
||||||
|
resolved_by: Optional[uuid.UUID] = None
|
||||||
|
superseded_by: Optional[uuid.UUID] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BookLinkRequest(BaseSchema):
|
||||||
|
"""`link` / `unlink` — direction is from the record in the path to `to_id`."""
|
||||||
|
|
||||||
|
to_id: uuid.UUID
|
||||||
|
relation: LinkRelation
|
||||||
|
|
||||||
|
|
||||||
|
class BookTagRequest(BaseSchema):
|
||||||
|
"""`tag` / `untag`."""
|
||||||
|
|
||||||
|
tag: str = Field(..., min_length=1, max_length=63)
|
||||||
|
|
||||||
|
|
||||||
|
class BookLinkResponse(BaseSchema):
|
||||||
|
from_id: uuid.UUID
|
||||||
|
relation: str
|
||||||
|
to_id: uuid.UUID
|
||||||
|
created_by: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class BookRecordResponse(BaseSchema):
|
||||||
|
"""Every write returns the stored record (D-70 rule 7). `number` is the
|
||||||
|
outward id, numbered per tenant (core-api T-7); `owner_tenant` is not
|
||||||
|
echoed back — it is implicit in the caller, following
|
||||||
|
src/domains/scribe/schemas.py's AddressResponse."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
number: int
|
||||||
|
kind: str
|
||||||
|
title: Optional[str]
|
||||||
|
body: str
|
||||||
|
checklist: List[dict] = Field(default_factory=list)
|
||||||
|
status: str
|
||||||
|
scope: str
|
||||||
|
priority: Optional[int]
|
||||||
|
pinned: bool
|
||||||
|
energy: Optional[str]
|
||||||
|
estimate_minutes: Optional[int]
|
||||||
|
next_step: Optional[str]
|
||||||
|
start_at: Optional[datetime]
|
||||||
|
due_at: Optional[datetime]
|
||||||
|
all_day: bool
|
||||||
|
remind_at: Optional[datetime]
|
||||||
|
recurrence: Optional[str]
|
||||||
|
recurrence_mode: Optional[str]
|
||||||
|
remind_to: Optional[str]
|
||||||
|
last_touched_at: datetime
|
||||||
|
archived_at: Optional[datetime]
|
||||||
|
trashed_at: Optional[datetime]
|
||||||
|
paused_at: Optional[datetime]
|
||||||
|
paused_statuses: Optional[dict]
|
||||||
|
source: Optional[str]
|
||||||
|
external_id: Optional[str]
|
||||||
|
colour: Optional[str]
|
||||||
|
created_by: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
tags: List[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class BookRecordDetailResponse(BookRecordResponse):
|
||||||
|
"""`show` — the record plus its outgoing links and backlinks."""
|
||||||
|
|
||||||
|
links: List[BookLinkResponse] = Field(default_factory=list)
|
||||||
|
backlinks: List[BookLinkResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class BookRecordListResponse(BaseSchema):
|
||||||
|
records: List[BookRecordResponse]
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
|
class BookEventResponse(BaseSchema):
|
||||||
|
id: uuid.UUID
|
||||||
|
record_id: uuid.UUID
|
||||||
|
at: datetime
|
||||||
|
identity: str
|
||||||
|
client: Optional[str]
|
||||||
|
action: str
|
||||||
|
diff: dict
|
||||||
|
|
||||||
|
|
||||||
|
class BookHistoryResponse(BaseSchema):
|
||||||
|
record_id: uuid.UUID
|
||||||
|
events: List[BookEventResponse]
|
||||||
@@ -0,0 +1,609 @@
|
|||||||
|
"""
|
||||||
|
Book Domain Service
|
||||||
|
|
||||||
|
Business logic for the Book — Notes, Tasks, Decisions, Questions and
|
||||||
|
Rejected options (D-68, D-70). Every mutating method stamps a `book_events`
|
||||||
|
row via `_write_event`; the table itself is append-only (migration 005's
|
||||||
|
trigger), so this module never updates or deletes an event.
|
||||||
|
|
||||||
|
Every method starts by requiring a tenant on the caller (`_require_tenant`,
|
||||||
|
same pattern as `src/domains/scribe/service.py`) and ends by filtering its
|
||||||
|
query on it: **a record in another tenant answers exactly like one that
|
||||||
|
does not exist** (core-api T-7 / workspace D-73), so no method here ever
|
||||||
|
confirms an id across that boundary. `link` and `add`'s `parent` convenience
|
||||||
|
both route through `_get_or_404`, so neither can create a link into another
|
||||||
|
tenant — which is also what keeps `show`'s links/backlinks free of a
|
||||||
|
separate tenant filter: every link touching a tenant's record is created
|
||||||
|
tenant-safe in the first place.
|
||||||
|
|
||||||
|
Methods take `session: AsyncSession` per call rather than storing it on
|
||||||
|
`self`, following `src/domains/dashboard/service.py`.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from sqlalchemy import delete, func, select
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.domains.auth.callers import Caller
|
||||||
|
from src.domains.book.models import (
|
||||||
|
DEFAULT_STATUS_BY_KIND,
|
||||||
|
STATUSES_BY_KIND,
|
||||||
|
BookEvent,
|
||||||
|
BookRecord,
|
||||||
|
BookRecordLink,
|
||||||
|
BookTag,
|
||||||
|
BookTenantCounter,
|
||||||
|
)
|
||||||
|
from src.domains.book.schemas import BookCloseRequest, BookRecordCreate, BookRecordUpdate
|
||||||
|
from src.domains.tenants.service import TenantError, get_tenants_service
|
||||||
|
from src.shared.config import get_settings
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BookError(Exception):
|
||||||
|
"""Raised for a Book-domain failure; the controller maps it to a response."""
|
||||||
|
|
||||||
|
def __init__(self, code: str, status_code: int = 400):
|
||||||
|
self.code = code
|
||||||
|
self.status_code = status_code
|
||||||
|
super().__init__(code)
|
||||||
|
|
||||||
|
|
||||||
|
class BookService:
|
||||||
|
"""Service for Book operations."""
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Internal helpers
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def _require_tenant(self, session: AsyncSession, caller: Caller) -> str:
|
||||||
|
"""Every Book operation needs a tenant; a caller without one gets a
|
||||||
|
clear error rather than a query that silently matches nothing. An
|
||||||
|
Office caller always has one (callers.py enforces it on every call);
|
||||||
|
a user-kind caller without a `tatlock_user` claim does not.
|
||||||
|
|
||||||
|
Delegates to the tenant registry's one enforcement point (core-api
|
||||||
|
T-8 / workspace D-73), which additionally refuses an unknown
|
||||||
|
tenant, a suspended one, or an Office consumer with no grant in it.
|
||||||
|
`TenantError` is translated to `BookError` so every call site and
|
||||||
|
the controller's single `except BookError` keep working unchanged.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await get_tenants_service().require_tenant(session, caller)
|
||||||
|
except TenantError as error:
|
||||||
|
raise BookError(error.code, error.status_code) from error
|
||||||
|
|
||||||
|
async def _get_or_404(self, session: AsyncSession, record_id, tenant: str) -> BookRecord:
|
||||||
|
"""A record outside `tenant` answers exactly like one that does not
|
||||||
|
exist — `session.get()` alone can't express that filter, since the
|
||||||
|
primary key is `id` only, so this is a `select` rather than a get."""
|
||||||
|
stmt = select(BookRecord).where(BookRecord.id == record_id, BookRecord.owner_tenant == tenant)
|
||||||
|
record = (await session.execute(stmt)).scalar_one_or_none()
|
||||||
|
if record is None:
|
||||||
|
raise BookError("not_found", 404)
|
||||||
|
return record
|
||||||
|
|
||||||
|
async def _allocate_number(self, session: AsyncSession, tenant: str) -> int:
|
||||||
|
"""
|
||||||
|
Allocate the next outward `number` for `tenant`, safe under
|
||||||
|
concurrency.
|
||||||
|
|
||||||
|
`INSERT ... ON CONFLICT DO NOTHING` guarantees the tenant's counter
|
||||||
|
row exists without racing its own creation (two simultaneous first
|
||||||
|
`add`s in a brand-new tenant can't both try to INSERT the same row
|
||||||
|
and fail), then `SELECT ... FOR UPDATE` locks that row for the rest
|
||||||
|
of this transaction — a second concurrent `add` in the same tenant
|
||||||
|
blocks on the lock until this one commits or rolls back, so two
|
||||||
|
concurrent creates in one tenant can never read the same
|
||||||
|
`next_number`. A different tenant's row is untouched, so tenants
|
||||||
|
never contend with each other. This is the dossier's suggested
|
||||||
|
shape (a per-tenant counter row taken FOR UPDATE in the same
|
||||||
|
transaction as the insert); the ON CONFLICT DO NOTHING bootstrap is
|
||||||
|
the one addition, needed because the plain shape alone still races
|
||||||
|
on a tenant's very first allocation.
|
||||||
|
"""
|
||||||
|
upsert = (
|
||||||
|
pg_insert(BookTenantCounter)
|
||||||
|
.values(owner_tenant=tenant, next_number=1)
|
||||||
|
.on_conflict_do_nothing(index_elements=["owner_tenant"])
|
||||||
|
)
|
||||||
|
await session.execute(upsert)
|
||||||
|
|
||||||
|
locked = (
|
||||||
|
select(BookTenantCounter)
|
||||||
|
.where(BookTenantCounter.owner_tenant == tenant)
|
||||||
|
.with_for_update()
|
||||||
|
)
|
||||||
|
counter = (await session.execute(locked)).scalar_one()
|
||||||
|
number = counter.next_number
|
||||||
|
counter.next_number = number + 1
|
||||||
|
return number
|
||||||
|
|
||||||
|
async def _tags_for(self, session: AsyncSession, record_id) -> List[str]:
|
||||||
|
stmt = select(BookTag.tag).where(BookTag.record_id == record_id).order_by(BookTag.tag)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def _write_event(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
record_id,
|
||||||
|
caller: Caller,
|
||||||
|
action: str,
|
||||||
|
diff: dict,
|
||||||
|
) -> None:
|
||||||
|
event = BookEvent(
|
||||||
|
record_id=record_id,
|
||||||
|
identity=caller.identity,
|
||||||
|
client=caller.consumer, # column name unchanged (no migration this round); T-4 retires X-Office-Client and records the consumer here instead
|
||||||
|
action=action,
|
||||||
|
diff=diff,
|
||||||
|
)
|
||||||
|
session.add(event)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
def _validate_status(self, kind: str, status: Optional[str]) -> str:
|
||||||
|
"""Resolve and validate a status for `kind`; defaults when unset."""
|
||||||
|
resolved = status or DEFAULT_STATUS_BY_KIND[kind]
|
||||||
|
if resolved not in STATUSES_BY_KIND[kind]:
|
||||||
|
raise BookError("invalid_status", 400)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
def _touch(self, record: BookRecord) -> None:
|
||||||
|
record.last_touched_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# add / show / list / find
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def add_record(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
data: BookRecordCreate,
|
||||||
|
caller: Caller,
|
||||||
|
) -> Tuple[BookRecord, List[str]]:
|
||||||
|
"""`add` — text-only input lands an unfiled Note (D-72). Sets
|
||||||
|
`owner_tenant` from the caller and allocates `number` (core-api T-7)."""
|
||||||
|
tenant = await self._require_tenant(session, caller)
|
||||||
|
status = self._validate_status(data.kind, data.status)
|
||||||
|
number = await self._allocate_number(session, tenant)
|
||||||
|
|
||||||
|
record = BookRecord(
|
||||||
|
owner_tenant=tenant,
|
||||||
|
number=number,
|
||||||
|
kind=data.kind,
|
||||||
|
title=data.title,
|
||||||
|
body=data.body,
|
||||||
|
checklist=[item.model_dump() for item in data.checklist],
|
||||||
|
status=status,
|
||||||
|
scope=data.scope,
|
||||||
|
priority=data.priority,
|
||||||
|
pinned=data.pinned,
|
||||||
|
energy=data.energy,
|
||||||
|
estimate_minutes=data.estimate_minutes,
|
||||||
|
next_step=data.next_step,
|
||||||
|
start_at=data.start_at,
|
||||||
|
due_at=data.due_at,
|
||||||
|
all_day=data.all_day,
|
||||||
|
remind_at=data.remind_at,
|
||||||
|
recurrence=data.recurrence,
|
||||||
|
recurrence_mode=data.recurrence_mode,
|
||||||
|
remind_to=data.remind_to,
|
||||||
|
source=data.source,
|
||||||
|
external_id=data.external_id,
|
||||||
|
colour=data.colour,
|
||||||
|
created_by=caller.identity,
|
||||||
|
)
|
||||||
|
session.add(record)
|
||||||
|
await session.flush() # assign record.id
|
||||||
|
|
||||||
|
tag_names = list(dict.fromkeys(data.tags)) # de-dup, keep order
|
||||||
|
for tag in tag_names:
|
||||||
|
session.add(BookTag(record_id=record.id, tag=tag))
|
||||||
|
|
||||||
|
if data.parent is not None:
|
||||||
|
# The parent is a link target (`relation="parent"`), so it goes
|
||||||
|
# through the same tenant-scoped lookup `link` uses — a parent
|
||||||
|
# in another tenant (or no such record at all) answers 404
|
||||||
|
# not_found, the same as an explicit `link` call would.
|
||||||
|
await self._get_or_404(session, data.parent, tenant)
|
||||||
|
session.add(BookRecordLink(
|
||||||
|
from_id=record.id,
|
||||||
|
relation="parent",
|
||||||
|
to_id=data.parent,
|
||||||
|
created_by=caller.identity,
|
||||||
|
))
|
||||||
|
|
||||||
|
await self._write_event(session, record.id, caller, "add", {"kind": data.kind, "status": status})
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(record)
|
||||||
|
|
||||||
|
logger.info(f"Book record added: kind={data.kind} id={record.id} number={number} tenant={tenant} by={caller.identity}")
|
||||||
|
return record, tag_names
|
||||||
|
|
||||||
|
async def show_record(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
record_id,
|
||||||
|
caller: Caller,
|
||||||
|
) -> Tuple[BookRecord, List[str], List[BookRecordLink], List[BookRecordLink]]:
|
||||||
|
"""`show` — the record, its tags, outgoing links and backlinks."""
|
||||||
|
tenant = await self._require_tenant(session, caller)
|
||||||
|
record = await self._get_or_404(session, record_id, tenant)
|
||||||
|
tags = await self._tags_for(session, record_id)
|
||||||
|
|
||||||
|
links_stmt = select(BookRecordLink).where(BookRecordLink.from_id == record_id)
|
||||||
|
links = list((await session.execute(links_stmt)).scalars().all())
|
||||||
|
|
||||||
|
backlinks_stmt = select(BookRecordLink).where(BookRecordLink.to_id == record_id)
|
||||||
|
backlinks = list((await session.execute(backlinks_stmt)).scalars().all())
|
||||||
|
|
||||||
|
return record, tags, links, backlinks
|
||||||
|
|
||||||
|
async def list_records(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
caller: Caller,
|
||||||
|
view: Optional[str] = None,
|
||||||
|
tag: Optional[str] = None,
|
||||||
|
scope: Optional[str] = None,
|
||||||
|
parent: Optional[object] = None,
|
||||||
|
number: Optional[int] = None,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> Tuple[List[Tuple[BookRecord, List[str]]], int]:
|
||||||
|
"""
|
||||||
|
`list` — the nine views of D-70, scoped to the caller's tenant.
|
||||||
|
Status-named views (`inbox`, `waiting`, `someday`, `done`) filter on
|
||||||
|
the matching task status and simply return nothing for kinds that
|
||||||
|
never carry it. `today` and `upcoming` bucket on `due_at` in
|
||||||
|
`book_timezone`; richer due/overdue handling is `due`/`next`, both
|
||||||
|
next round. `number=` finds the one record with that outward id in
|
||||||
|
the caller's tenant (core-api T-7).
|
||||||
|
"""
|
||||||
|
tenant = await self._require_tenant(session, caller)
|
||||||
|
conditions = [BookRecord.owner_tenant == tenant]
|
||||||
|
|
||||||
|
if view in ("inbox", "waiting", "someday", "done"):
|
||||||
|
conditions.append(BookRecord.status == view)
|
||||||
|
elif view == "pinned":
|
||||||
|
conditions.append(BookRecord.pinned.is_(True))
|
||||||
|
elif view == "archive":
|
||||||
|
conditions.append(BookRecord.archived_at.isnot(None))
|
||||||
|
elif view == "trash":
|
||||||
|
conditions.append(BookRecord.trashed_at.isnot(None))
|
||||||
|
elif view in ("today", "upcoming"):
|
||||||
|
tz = ZoneInfo(get_settings().book_timezone)
|
||||||
|
local_now = datetime.now(tz)
|
||||||
|
day_start = local_now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
day_end = day_start.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||||
|
conditions.append(BookRecord.due_at.isnot(None))
|
||||||
|
if view == "today":
|
||||||
|
conditions.append(BookRecord.due_at >= day_start)
|
||||||
|
conditions.append(BookRecord.due_at <= day_end)
|
||||||
|
else:
|
||||||
|
conditions.append(BookRecord.due_at > day_end)
|
||||||
|
|
||||||
|
if view not in ("archive", "trash"):
|
||||||
|
conditions.append(BookRecord.archived_at.is_(None))
|
||||||
|
conditions.append(BookRecord.trashed_at.is_(None))
|
||||||
|
|
||||||
|
if scope is not None:
|
||||||
|
conditions.append(BookRecord.scope == scope)
|
||||||
|
|
||||||
|
if parent is not None:
|
||||||
|
parent_subquery = (
|
||||||
|
select(BookRecordLink.from_id)
|
||||||
|
.where(BookRecordLink.relation == "parent", BookRecordLink.to_id == parent)
|
||||||
|
)
|
||||||
|
conditions.append(BookRecord.id.in_(parent_subquery))
|
||||||
|
|
||||||
|
if tag is not None:
|
||||||
|
tag_subquery = select(BookTag.record_id).where(BookTag.tag == tag)
|
||||||
|
conditions.append(BookRecord.id.in_(tag_subquery))
|
||||||
|
|
||||||
|
if number is not None:
|
||||||
|
conditions.append(BookRecord.number == number)
|
||||||
|
|
||||||
|
base_query = select(BookRecord).where(*conditions)
|
||||||
|
|
||||||
|
count_query = select(func.count()).select_from(base_query.subquery())
|
||||||
|
total = (await session.execute(count_query)).scalar() or 0
|
||||||
|
|
||||||
|
query = (
|
||||||
|
base_query
|
||||||
|
.order_by(BookRecord.last_touched_at.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
records = list((await session.execute(query)).scalars().all())
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for record in records:
|
||||||
|
tags = await self._tags_for(session, record.id)
|
||||||
|
results.append((record, tags))
|
||||||
|
|
||||||
|
return results, total
|
||||||
|
|
||||||
|
async def find_records(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
caller: Caller,
|
||||||
|
q: str,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> Tuple[List[Tuple[BookRecord, List[str]]], int]:
|
||||||
|
"""`find` — full-text search over title and body, scoped to the
|
||||||
|
caller's tenant."""
|
||||||
|
tenant = await self._require_tenant(session, caller)
|
||||||
|
tsquery = func.websearch_to_tsquery("simple", q)
|
||||||
|
base_query = select(BookRecord).where(
|
||||||
|
BookRecord.owner_tenant == tenant, BookRecord.search.op("@@")(tsquery)
|
||||||
|
)
|
||||||
|
|
||||||
|
count_query = select(func.count()).select_from(base_query.subquery())
|
||||||
|
total = (await session.execute(count_query)).scalar() or 0
|
||||||
|
|
||||||
|
query = base_query.order_by(BookRecord.updated_at.desc()).offset(offset).limit(limit)
|
||||||
|
records = list((await session.execute(query)).scalars().all())
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for record in records:
|
||||||
|
tags = await self._tags_for(session, record.id)
|
||||||
|
results.append((record, tags))
|
||||||
|
|
||||||
|
return results, total
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# change / close
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def change_record(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
record_id,
|
||||||
|
data: BookRecordUpdate,
|
||||||
|
caller: Caller,
|
||||||
|
) -> Tuple[BookRecord, List[str]]:
|
||||||
|
"""`change` — a partial update; only fields set on the request move."""
|
||||||
|
tenant = await self._require_tenant(session, caller)
|
||||||
|
record = await self._get_or_404(session, record_id, tenant)
|
||||||
|
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
diff: dict = {}
|
||||||
|
|
||||||
|
new_kind = update_data.get("kind", record.kind)
|
||||||
|
if "status" in update_data or "kind" in update_data:
|
||||||
|
resolved_status = self._validate_status(new_kind, update_data.get("status"))
|
||||||
|
update_data["status"] = resolved_status
|
||||||
|
|
||||||
|
if "checklist" in update_data:
|
||||||
|
update_data["checklist"] = [
|
||||||
|
item if isinstance(item, dict) else item.model_dump()
|
||||||
|
for item in update_data["checklist"]
|
||||||
|
]
|
||||||
|
|
||||||
|
for field, value in update_data.items():
|
||||||
|
before = getattr(record, field)
|
||||||
|
if before != value:
|
||||||
|
diff[field] = {"from": str(before), "to": str(value)}
|
||||||
|
setattr(record, field, value)
|
||||||
|
|
||||||
|
self._touch(record)
|
||||||
|
|
||||||
|
if diff:
|
||||||
|
await self._write_event(session, record_id, caller, "change", diff)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(record)
|
||||||
|
|
||||||
|
tags = await self._tags_for(session, record_id)
|
||||||
|
logger.info(f"Book record changed: id={record_id} by={caller.identity} fields={list(diff)}")
|
||||||
|
return record, tags
|
||||||
|
|
||||||
|
async def close_record(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
record_id,
|
||||||
|
data: BookCloseRequest,
|
||||||
|
caller: Caller,
|
||||||
|
) -> Tuple[BookRecord, List[str]]:
|
||||||
|
"""
|
||||||
|
`close` — a Task is done or dropped; a Question is resolved into a
|
||||||
|
Decision (`resolves` link, status `settled`); a Decision is
|
||||||
|
superseded by a newer one (`supersedes` link, status `superseded`).
|
||||||
|
`resolved_by`/`superseded_by` are link targets, so each is looked up
|
||||||
|
in the caller's tenant the same as `link` — a target in another
|
||||||
|
tenant answers 404 not_found.
|
||||||
|
"""
|
||||||
|
tenant = await self._require_tenant(session, caller)
|
||||||
|
record = await self._get_or_404(session, record_id, tenant)
|
||||||
|
diff: dict = {}
|
||||||
|
|
||||||
|
if record.kind == "task":
|
||||||
|
if data.outcome is None:
|
||||||
|
raise BookError("invalid_status", 400)
|
||||||
|
record.status = data.outcome
|
||||||
|
diff = {"status": data.outcome}
|
||||||
|
|
||||||
|
elif record.kind == "question":
|
||||||
|
if data.resolved_by is None:
|
||||||
|
raise BookError("invalid_status", 400)
|
||||||
|
decision = await self._get_or_404(session, data.resolved_by, tenant)
|
||||||
|
if decision.kind != "decision":
|
||||||
|
raise BookError("invalid_status", 400)
|
||||||
|
session.add(BookRecordLink(
|
||||||
|
from_id=decision.id,
|
||||||
|
relation="resolves",
|
||||||
|
to_id=record.id,
|
||||||
|
created_by=caller.identity,
|
||||||
|
))
|
||||||
|
record.status = "settled"
|
||||||
|
diff = {"status": "settled", "resolved_by": str(decision.id)}
|
||||||
|
|
||||||
|
elif record.kind == "decision":
|
||||||
|
if data.superseded_by is None:
|
||||||
|
raise BookError("invalid_status", 400)
|
||||||
|
new_decision = await self._get_or_404(session, data.superseded_by, tenant)
|
||||||
|
if new_decision.kind != "decision":
|
||||||
|
raise BookError("invalid_status", 400)
|
||||||
|
session.add(BookRecordLink(
|
||||||
|
from_id=new_decision.id,
|
||||||
|
relation="supersedes",
|
||||||
|
to_id=record.id,
|
||||||
|
created_by=caller.identity,
|
||||||
|
))
|
||||||
|
record.status = "superseded"
|
||||||
|
diff = {"status": "superseded", "superseded_by": str(new_decision.id)}
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Notes and Rejected options have no `close` transition.
|
||||||
|
raise BookError("invalid_status", 400)
|
||||||
|
|
||||||
|
self._touch(record)
|
||||||
|
await self._write_event(session, record_id, caller, "close", diff)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(record)
|
||||||
|
|
||||||
|
tags = await self._tags_for(session, record_id)
|
||||||
|
logger.info(f"Book record closed: id={record_id} by={caller.identity} diff={diff}")
|
||||||
|
return record, tags
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# link / unlink
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def link_records(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
record_id,
|
||||||
|
to_id,
|
||||||
|
relation: str,
|
||||||
|
caller: Caller,
|
||||||
|
) -> Tuple[BookRecord, List[str]]:
|
||||||
|
"""`link` refuses a target in another tenant the same way a missing
|
||||||
|
one is refused — both are 404 not_found (core-api T-7)."""
|
||||||
|
tenant = await self._require_tenant(session, caller)
|
||||||
|
record = await self._get_or_404(session, record_id, tenant)
|
||||||
|
await self._get_or_404(session, to_id, tenant) # the target must exist in the same tenant
|
||||||
|
|
||||||
|
session.add(BookRecordLink(
|
||||||
|
from_id=record_id,
|
||||||
|
relation=relation,
|
||||||
|
to_id=to_id,
|
||||||
|
created_by=caller.identity,
|
||||||
|
))
|
||||||
|
await self._write_event(
|
||||||
|
session, record_id, caller, "link", {"relation": relation, "to_id": str(to_id)}
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(record)
|
||||||
|
|
||||||
|
tags = await self._tags_for(session, record_id)
|
||||||
|
return record, tags
|
||||||
|
|
||||||
|
async def unlink_records(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
record_id,
|
||||||
|
to_id,
|
||||||
|
relation: str,
|
||||||
|
caller: Caller,
|
||||||
|
) -> Tuple[BookRecord, List[str]]:
|
||||||
|
tenant = await self._require_tenant(session, caller)
|
||||||
|
record = await self._get_or_404(session, record_id, tenant)
|
||||||
|
|
||||||
|
stmt = delete(BookRecordLink).where(
|
||||||
|
BookRecordLink.from_id == record_id,
|
||||||
|
BookRecordLink.relation == relation,
|
||||||
|
BookRecordLink.to_id == to_id,
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
if result.rowcount == 0:
|
||||||
|
raise BookError("not_found", 404)
|
||||||
|
|
||||||
|
await self._write_event(
|
||||||
|
session, record_id, caller, "unlink", {"relation": relation, "to_id": str(to_id)}
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(record)
|
||||||
|
|
||||||
|
tags = await self._tags_for(session, record_id)
|
||||||
|
return record, tags
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# tag / untag
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def tag_record(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
record_id,
|
||||||
|
tag: str,
|
||||||
|
caller: Caller,
|
||||||
|
) -> Tuple[BookRecord, List[str]]:
|
||||||
|
tenant = await self._require_tenant(session, caller)
|
||||||
|
record = await self._get_or_404(session, record_id, tenant)
|
||||||
|
|
||||||
|
existing = await session.get(BookTag, (record_id, tag))
|
||||||
|
if existing is None:
|
||||||
|
session.add(BookTag(record_id=record_id, tag=tag))
|
||||||
|
await self._write_event(session, record_id, caller, "tag", {"tag": tag})
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
tags = await self._tags_for(session, record_id)
|
||||||
|
return record, tags
|
||||||
|
|
||||||
|
async def untag_record(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
record_id,
|
||||||
|
tag: str,
|
||||||
|
caller: Caller,
|
||||||
|
) -> Tuple[BookRecord, List[str]]:
|
||||||
|
tenant = await self._require_tenant(session, caller)
|
||||||
|
record = await self._get_or_404(session, record_id, tenant)
|
||||||
|
|
||||||
|
stmt = delete(BookTag).where(BookTag.record_id == record_id, BookTag.tag == tag)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
if result.rowcount > 0:
|
||||||
|
await self._write_event(session, record_id, caller, "untag", {"tag": tag})
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
tags = await self._tags_for(session, record_id)
|
||||||
|
return record, tags
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# history
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def get_history(
|
||||||
|
self, session: AsyncSession, record_id, caller: Caller, limit: int = 100
|
||||||
|
) -> List[BookEvent]:
|
||||||
|
tenant = await self._require_tenant(session, caller)
|
||||||
|
await self._get_or_404(session, record_id, tenant)
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(BookEvent)
|
||||||
|
.where(BookEvent.record_id == record_id)
|
||||||
|
.order_by(BookEvent.at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
_book_service: Optional[BookService] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_book_service() -> BookService:
|
||||||
|
"""Get singleton Book service instance"""
|
||||||
|
global _book_service
|
||||||
|
if _book_service is None:
|
||||||
|
_book_service = BookService()
|
||||||
|
return _book_service
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""
|
||||||
|
Dashboard Domain
|
||||||
|
|
||||||
|
Provides dashboard management endpoints including quick links.
|
||||||
|
"""
|
||||||
|
from src.domains.dashboard.controller import dashboard_controller
|
||||||
|
|
||||||
|
__all__ = ["dashboard_controller"]
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
"""
|
||||||
|
Dashboard Controller
|
||||||
|
|
||||||
|
Provides API endpoints for dashboard management including quick links.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||||
|
from typing import Dict, Optional
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.shared.base import BaseController
|
||||||
|
from src.shared.database import get_async_session
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
from src.domains.auth.oidc import get_current_user, get_optional_user
|
||||||
|
from src.domains.dashboard.service import get_dashboard_service
|
||||||
|
from src.domains.dashboard.schemas import (
|
||||||
|
QuickLinkCreate,
|
||||||
|
QuickLinkUpdate,
|
||||||
|
QuickLinkResponse,
|
||||||
|
QuickLinkListResponse,
|
||||||
|
QuickLinkReorderRequest,
|
||||||
|
QuickLinkReorderResponse,
|
||||||
|
DashboardWidgetCreate,
|
||||||
|
DashboardWidgetUpdate,
|
||||||
|
DashboardWidgetResponse,
|
||||||
|
DashboardWidgetListResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardController(BaseController):
|
||||||
|
"""
|
||||||
|
Controller for dashboard operations
|
||||||
|
|
||||||
|
Provides endpoints for:
|
||||||
|
- Quick links CRUD
|
||||||
|
- Quick links reordering
|
||||||
|
- Dashboard widgets management
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(prefix="/dashboard", tags=["Dashboard"])
|
||||||
|
|
||||||
|
def create_router(self) -> APIRouter:
|
||||||
|
"""Create and configure the router"""
|
||||||
|
router = APIRouter(prefix=self.prefix, tags=self.tags)
|
||||||
|
service = get_dashboard_service()
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# Quick Links
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/quick-links",
|
||||||
|
response_model=QuickLinkListResponse,
|
||||||
|
summary="List quick links"
|
||||||
|
)
|
||||||
|
async def list_quick_links(
|
||||||
|
category: Optional[str] = Query(None, description="Filter by category"),
|
||||||
|
include_global: bool = Query(True, description="Include global links"),
|
||||||
|
visible_only: bool = Query(True, description="Only visible links"),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: Optional[Dict] = Depends(get_optional_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List quick links for the current user
|
||||||
|
|
||||||
|
Returns user-specific links plus global links (if include_global=True).
|
||||||
|
"""
|
||||||
|
user_id = user.get("sub") if user else None
|
||||||
|
|
||||||
|
links = await service.get_quick_links(
|
||||||
|
session=session,
|
||||||
|
user_id=user_id,
|
||||||
|
include_global=include_global,
|
||||||
|
category=category,
|
||||||
|
visible_only=visible_only,
|
||||||
|
)
|
||||||
|
|
||||||
|
return QuickLinkListResponse(
|
||||||
|
links=[QuickLinkResponse.model_validate(link, from_attributes=True) for link in links],
|
||||||
|
total=len(links)
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/quick-links/{link_id}",
|
||||||
|
response_model=QuickLinkResponse,
|
||||||
|
summary="Get a quick link"
|
||||||
|
)
|
||||||
|
async def get_quick_link(
|
||||||
|
link_id: int,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: Optional[Dict] = Depends(get_optional_user),
|
||||||
|
):
|
||||||
|
"""Get a specific quick link by ID"""
|
||||||
|
user_id = user.get("sub") if user else None
|
||||||
|
|
||||||
|
link = await service.get_quick_link(session, link_id, user_id)
|
||||||
|
if not link:
|
||||||
|
raise HTTPException(status_code=404, detail="Quick link not found")
|
||||||
|
|
||||||
|
return QuickLinkResponse.model_validate(link, from_attributes=True)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/quick-links",
|
||||||
|
response_model=QuickLinkResponse,
|
||||||
|
status_code=201,
|
||||||
|
summary="Create a quick link"
|
||||||
|
)
|
||||||
|
async def create_quick_link(
|
||||||
|
data: QuickLinkCreate,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: Dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a new quick link for the current user
|
||||||
|
|
||||||
|
Links are user-specific by default. Admins can create global links
|
||||||
|
by setting user_id to null.
|
||||||
|
"""
|
||||||
|
user_id = user.get("sub")
|
||||||
|
|
||||||
|
link = await service.create_quick_link(session, data, user_id)
|
||||||
|
|
||||||
|
logger.info(f"Quick link created: {link.title} by user {user.get('preferred_username')}")
|
||||||
|
|
||||||
|
return QuickLinkResponse.model_validate(link, from_attributes=True)
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/quick-links/{link_id}",
|
||||||
|
response_model=QuickLinkResponse,
|
||||||
|
summary="Update a quick link"
|
||||||
|
)
|
||||||
|
async def update_quick_link(
|
||||||
|
link_id: int,
|
||||||
|
data: QuickLinkUpdate,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: Dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Update an existing quick link"""
|
||||||
|
user_id = user.get("sub")
|
||||||
|
|
||||||
|
link = await service.update_quick_link(session, link_id, data, user_id)
|
||||||
|
if not link:
|
||||||
|
raise HTTPException(status_code=404, detail="Quick link not found or not authorized")
|
||||||
|
|
||||||
|
return QuickLinkResponse.model_validate(link, from_attributes=True)
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/quick-links/{link_id}",
|
||||||
|
status_code=204,
|
||||||
|
summary="Delete a quick link"
|
||||||
|
)
|
||||||
|
async def delete_quick_link(
|
||||||
|
link_id: int,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: Dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Delete a quick link"""
|
||||||
|
user_id = user.get("sub")
|
||||||
|
|
||||||
|
success = await service.delete_quick_link(session, link_id, user_id)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=404, detail="Quick link not found or not authorized")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/quick-links/reorder",
|
||||||
|
response_model=QuickLinkReorderResponse,
|
||||||
|
summary="Reorder quick links"
|
||||||
|
)
|
||||||
|
async def reorder_quick_links(
|
||||||
|
data: QuickLinkReorderRequest,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: Dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Reorder quick links by providing link IDs in desired order
|
||||||
|
|
||||||
|
The position of each link will be set to its index in the provided list.
|
||||||
|
"""
|
||||||
|
user_id = user.get("sub")
|
||||||
|
|
||||||
|
reordered = await service.reorder_quick_links(session, data.link_ids, user_id)
|
||||||
|
|
||||||
|
return QuickLinkReorderResponse(
|
||||||
|
success=True,
|
||||||
|
message=f"Reordered {reordered} links",
|
||||||
|
reordered_count=reordered
|
||||||
|
)
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# Dashboard Widgets
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/widgets",
|
||||||
|
response_model=DashboardWidgetListResponse,
|
||||||
|
summary="List dashboard widgets"
|
||||||
|
)
|
||||||
|
async def list_widgets(
|
||||||
|
include_defaults: bool = Query(True, description="Include default widgets"),
|
||||||
|
visible_only: bool = Query(True, description="Only visible widgets"),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: Optional[Dict] = Depends(get_optional_user),
|
||||||
|
):
|
||||||
|
"""List dashboard widgets for the current user"""
|
||||||
|
user_id = user.get("sub") if user else None
|
||||||
|
|
||||||
|
widgets = await service.get_widgets(
|
||||||
|
session=session,
|
||||||
|
user_id=user_id,
|
||||||
|
include_defaults=include_defaults,
|
||||||
|
visible_only=visible_only,
|
||||||
|
)
|
||||||
|
|
||||||
|
return DashboardWidgetListResponse(
|
||||||
|
widgets=[DashboardWidgetResponse.model_validate(w, from_attributes=True) for w in widgets],
|
||||||
|
total=len(widgets)
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/widgets/{widget_id}",
|
||||||
|
response_model=DashboardWidgetResponse,
|
||||||
|
summary="Get a dashboard widget"
|
||||||
|
)
|
||||||
|
async def get_widget(
|
||||||
|
widget_id: int,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: Optional[Dict] = Depends(get_optional_user),
|
||||||
|
):
|
||||||
|
"""Get a specific dashboard widget by ID"""
|
||||||
|
user_id = user.get("sub") if user else None
|
||||||
|
|
||||||
|
widget = await service.get_widget(session, widget_id, user_id)
|
||||||
|
if not widget:
|
||||||
|
raise HTTPException(status_code=404, detail="Widget not found")
|
||||||
|
|
||||||
|
return DashboardWidgetResponse.model_validate(widget, from_attributes=True)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/widgets",
|
||||||
|
response_model=DashboardWidgetResponse,
|
||||||
|
status_code=201,
|
||||||
|
summary="Create a dashboard widget"
|
||||||
|
)
|
||||||
|
async def create_widget(
|
||||||
|
data: DashboardWidgetCreate,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: Dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Create a new dashboard widget"""
|
||||||
|
user_id = user.get("sub")
|
||||||
|
|
||||||
|
widget = await service.create_widget(session, data, user_id)
|
||||||
|
|
||||||
|
logger.info(f"Widget created: {widget.widget_type} by user {user.get('preferred_username')}")
|
||||||
|
|
||||||
|
return DashboardWidgetResponse.model_validate(widget, from_attributes=True)
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/widgets/{widget_id}",
|
||||||
|
response_model=DashboardWidgetResponse,
|
||||||
|
summary="Update a dashboard widget"
|
||||||
|
)
|
||||||
|
async def update_widget(
|
||||||
|
widget_id: int,
|
||||||
|
data: DashboardWidgetUpdate,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: Dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Update an existing dashboard widget"""
|
||||||
|
user_id = user.get("sub")
|
||||||
|
|
||||||
|
widget = await service.update_widget(session, widget_id, data, user_id)
|
||||||
|
if not widget:
|
||||||
|
raise HTTPException(status_code=404, detail="Widget not found or not authorized")
|
||||||
|
|
||||||
|
return DashboardWidgetResponse.model_validate(widget, from_attributes=True)
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/widgets/{widget_id}",
|
||||||
|
status_code=204,
|
||||||
|
summary="Delete a dashboard widget"
|
||||||
|
)
|
||||||
|
async def delete_widget(
|
||||||
|
widget_id: int,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: Dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Delete a dashboard widget"""
|
||||||
|
user_id = user.get("sub")
|
||||||
|
|
||||||
|
success = await service.delete_widget(session, widget_id, user_id)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=404, detail="Widget not found or not authorized")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
# Create controller instance
|
||||||
|
dashboard_controller = DashboardController()
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""
|
||||||
|
Dashboard Domain Models
|
||||||
|
|
||||||
|
SQLAlchemy models for dashboard-related data.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from src.shared.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class QuickLink(Base):
|
||||||
|
"""Quick link for dashboard jump pad"""
|
||||||
|
__tablename__ = "quick_links"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# Link content
|
||||||
|
title = Column(String(100), nullable=False)
|
||||||
|
url = Column(String(500), nullable=False)
|
||||||
|
icon = Column(String(100), nullable=True) # Icon name or URL
|
||||||
|
description = Column(String(255), nullable=True)
|
||||||
|
|
||||||
|
# Categorization
|
||||||
|
category = Column(String(50), nullable=True) # e.g., "services", "tools", "docs"
|
||||||
|
|
||||||
|
# User association - nullable for global links
|
||||||
|
user_id = Column(String(255), nullable=True, index=True) # Authentik user ID
|
||||||
|
|
||||||
|
# Ordering and display
|
||||||
|
position = Column(Integer, default=0)
|
||||||
|
is_visible = Column(Boolean, default=True)
|
||||||
|
link_type = Column(String(20), default="iframe") # "iframe", "new_tab", etc.
|
||||||
|
|
||||||
|
# Styling
|
||||||
|
color = Column(String(20), nullable=True) # Hex color for the link card
|
||||||
|
background_color = Column(String(20), nullable=True)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<QuickLink(id={self.id}, title='{self.title}', user_id='{self.user_id}')>"
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardWidget(Base):
|
||||||
|
"""Dashboard widget configuration"""
|
||||||
|
__tablename__ = "dashboard_widgets"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# Widget identification
|
||||||
|
widget_type = Column(String(50), nullable=False) # e.g., "quick_links", "service_status", "weather"
|
||||||
|
|
||||||
|
# User association - nullable for default widgets
|
||||||
|
user_id = Column(String(255), nullable=True, index=True)
|
||||||
|
|
||||||
|
# Position and sizing
|
||||||
|
position_x = Column(Integer, default=0)
|
||||||
|
position_y = Column(Integer, default=0)
|
||||||
|
width = Column(Integer, default=1)
|
||||||
|
height = Column(Integer, default=1)
|
||||||
|
|
||||||
|
# Widget-specific configuration (JSON)
|
||||||
|
config = Column(Text, nullable=True) # JSON string for widget-specific settings
|
||||||
|
|
||||||
|
# Display
|
||||||
|
is_visible = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<DashboardWidget(id={self.id}, type='{self.widget_type}', user_id='{self.user_id}')>"
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""
|
||||||
|
Dashboard Domain Schemas
|
||||||
|
|
||||||
|
Pydantic schemas for dashboard endpoints.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from src.shared.base import BaseSchema
|
||||||
|
|
||||||
|
|
||||||
|
# Quick Link Schemas
|
||||||
|
class QuickLinkBase(BaseSchema):
|
||||||
|
"""Base schema for quick links"""
|
||||||
|
title: str = Field(..., min_length=1, max_length=100, description="Link title")
|
||||||
|
url: str = Field(..., min_length=1, max_length=500, description="Link URL")
|
||||||
|
icon: Optional[str] = Field(None, max_length=100, description="Icon name or URL")
|
||||||
|
description: Optional[str] = Field(None, max_length=255, description="Link description")
|
||||||
|
category: Optional[str] = Field(None, max_length=50, description="Link category")
|
||||||
|
color: Optional[str] = Field(None, max_length=20, description="Hex color for link card")
|
||||||
|
background_color: Optional[str] = Field(None, max_length=20, description="Background hex color")
|
||||||
|
|
||||||
|
|
||||||
|
class QuickLinkCreate(QuickLinkBase):
|
||||||
|
"""Schema for creating a quick link"""
|
||||||
|
position: Optional[int] = Field(0, ge=0, description="Display position")
|
||||||
|
is_visible: Optional[bool] = Field(True, description="Whether link is visible")
|
||||||
|
link_type: Optional[str] = Field("iframe", max_length=20, description="Link type: iframe, new_tab")
|
||||||
|
|
||||||
|
|
||||||
|
class QuickLinkUpdate(BaseSchema):
|
||||||
|
"""Schema for updating a quick link"""
|
||||||
|
title: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||||
|
url: Optional[str] = Field(None, min_length=1, max_length=500)
|
||||||
|
icon: Optional[str] = Field(None, max_length=100)
|
||||||
|
description: Optional[str] = Field(None, max_length=255)
|
||||||
|
category: Optional[str] = Field(None, max_length=50)
|
||||||
|
position: Optional[int] = Field(None, ge=0)
|
||||||
|
is_visible: Optional[bool] = None
|
||||||
|
link_type: Optional[str] = Field(None, max_length=20)
|
||||||
|
color: Optional[str] = Field(None, max_length=20)
|
||||||
|
background_color: Optional[str] = Field(None, max_length=20)
|
||||||
|
|
||||||
|
|
||||||
|
class QuickLinkResponse(QuickLinkBase):
|
||||||
|
"""Schema for quick link response"""
|
||||||
|
id: int
|
||||||
|
user_id: Optional[str] = None
|
||||||
|
position: int
|
||||||
|
is_visible: bool
|
||||||
|
link_type: str = "iframe"
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class QuickLinkListResponse(BaseSchema):
|
||||||
|
"""Response for list of quick links"""
|
||||||
|
links: List[QuickLinkResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class QuickLinkReorderRequest(BaseSchema):
|
||||||
|
"""Request to reorder quick links"""
|
||||||
|
link_ids: List[int] = Field(..., description="List of link IDs in desired order")
|
||||||
|
|
||||||
|
|
||||||
|
class QuickLinkReorderResponse(BaseSchema):
|
||||||
|
"""Response after reordering"""
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
reordered_count: int
|
||||||
|
|
||||||
|
|
||||||
|
# Dashboard Widget Schemas
|
||||||
|
class DashboardWidgetBase(BaseSchema):
|
||||||
|
"""Base schema for dashboard widgets"""
|
||||||
|
widget_type: str = Field(..., min_length=1, max_length=50, description="Widget type identifier")
|
||||||
|
position_x: int = Field(0, ge=0, description="X position on grid")
|
||||||
|
position_y: int = Field(0, ge=0, description="Y position on grid")
|
||||||
|
width: int = Field(1, ge=1, le=12, description="Widget width in grid units")
|
||||||
|
height: int = Field(1, ge=1, le=12, description="Widget height in grid units")
|
||||||
|
config: Optional[str] = Field(None, description="JSON config for widget")
|
||||||
|
is_visible: bool = Field(True, description="Whether widget is visible")
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardWidgetCreate(DashboardWidgetBase):
|
||||||
|
"""Schema for creating a widget"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardWidgetUpdate(BaseSchema):
|
||||||
|
"""Schema for updating a widget"""
|
||||||
|
position_x: Optional[int] = Field(None, ge=0)
|
||||||
|
position_y: Optional[int] = Field(None, ge=0)
|
||||||
|
width: Optional[int] = Field(None, ge=1, le=12)
|
||||||
|
height: Optional[int] = Field(None, ge=1, le=12)
|
||||||
|
config: Optional[str] = None
|
||||||
|
is_visible: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardWidgetResponse(DashboardWidgetBase):
|
||||||
|
"""Schema for widget response"""
|
||||||
|
id: int
|
||||||
|
user_id: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardWidgetListResponse(BaseSchema):
|
||||||
|
"""Response for list of widgets"""
|
||||||
|
widgets: List[DashboardWidgetResponse]
|
||||||
|
total: int
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
"""
|
||||||
|
Dashboard Domain Service
|
||||||
|
|
||||||
|
Business logic for dashboard operations.
|
||||||
|
"""
|
||||||
|
from typing import Optional, List
|
||||||
|
from sqlalchemy import select, update, delete
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
from src.domains.dashboard.models import QuickLink, DashboardWidget
|
||||||
|
from src.domains.dashboard.schemas import (
|
||||||
|
QuickLinkCreate,
|
||||||
|
QuickLinkUpdate,
|
||||||
|
QuickLinkResponse,
|
||||||
|
DashboardWidgetCreate,
|
||||||
|
DashboardWidgetUpdate,
|
||||||
|
DashboardWidgetResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardService:
|
||||||
|
"""Service for dashboard operations"""
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Quick Links
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def get_quick_links(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
include_global: bool = True,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
visible_only: bool = True,
|
||||||
|
) -> List[QuickLink]:
|
||||||
|
"""
|
||||||
|
Get quick links for a user
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
user_id: User ID to filter by (None for global only)
|
||||||
|
include_global: Whether to include global links (user_id=None)
|
||||||
|
category: Optional category filter
|
||||||
|
visible_only: Only return visible links
|
||||||
|
"""
|
||||||
|
conditions = []
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
if include_global:
|
||||||
|
from sqlalchemy import or_
|
||||||
|
conditions.append(or_(QuickLink.user_id == user_id, QuickLink.user_id.is_(None)))
|
||||||
|
else:
|
||||||
|
conditions.append(QuickLink.user_id == user_id)
|
||||||
|
else:
|
||||||
|
conditions.append(QuickLink.user_id.is_(None))
|
||||||
|
|
||||||
|
if category:
|
||||||
|
conditions.append(QuickLink.category == category)
|
||||||
|
|
||||||
|
if visible_only:
|
||||||
|
conditions.append(QuickLink.is_visible == True)
|
||||||
|
|
||||||
|
stmt = select(QuickLink).where(*conditions).order_by(QuickLink.position, QuickLink.id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def get_quick_link(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
link_id: int,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> Optional[QuickLink]:
|
||||||
|
"""Get a specific quick link by ID"""
|
||||||
|
conditions = [QuickLink.id == link_id]
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
from sqlalchemy import or_
|
||||||
|
conditions.append(or_(QuickLink.user_id == user_id, QuickLink.user_id.is_(None)))
|
||||||
|
|
||||||
|
stmt = select(QuickLink).where(*conditions)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def create_quick_link(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
data: QuickLinkCreate,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> QuickLink:
|
||||||
|
"""Create a new quick link"""
|
||||||
|
# Get max position for this user
|
||||||
|
stmt = select(QuickLink.position).where(
|
||||||
|
QuickLink.user_id == user_id if user_id else QuickLink.user_id.is_(None)
|
||||||
|
).order_by(QuickLink.position.desc()).limit(1)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
max_pos = result.scalar_one_or_none() or -1
|
||||||
|
|
||||||
|
link = QuickLink(
|
||||||
|
title=data.title,
|
||||||
|
url=data.url,
|
||||||
|
icon=data.icon,
|
||||||
|
description=data.description,
|
||||||
|
category=data.category,
|
||||||
|
position=data.position if data.position > 0 else max_pos + 1,
|
||||||
|
is_visible=data.is_visible,
|
||||||
|
color=data.color,
|
||||||
|
background_color=data.background_color,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
session.add(link)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(link)
|
||||||
|
|
||||||
|
logger.info(f"Created quick link: {link.title} (id={link.id}, user={user_id})")
|
||||||
|
return link
|
||||||
|
|
||||||
|
async def update_quick_link(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
link_id: int,
|
||||||
|
data: QuickLinkUpdate,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> Optional[QuickLink]:
|
||||||
|
"""Update a quick link"""
|
||||||
|
link = await self.get_quick_link(session, link_id, user_id)
|
||||||
|
if not link:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Only allow updating own links or global links for admins
|
||||||
|
if link.user_id and link.user_id != user_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(link, field, value)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(link)
|
||||||
|
|
||||||
|
logger.info(f"Updated quick link: {link.title} (id={link.id})")
|
||||||
|
return link
|
||||||
|
|
||||||
|
async def delete_quick_link(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
link_id: int,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Delete a quick link"""
|
||||||
|
link = await self.get_quick_link(session, link_id, user_id)
|
||||||
|
if not link:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Only allow deleting own links
|
||||||
|
if link.user_id and link.user_id != user_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
await session.delete(link)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Deleted quick link: id={link_id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def reorder_quick_links(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
link_ids: List[int],
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> int:
|
||||||
|
"""Reorder quick links by updating positions"""
|
||||||
|
reordered = 0
|
||||||
|
|
||||||
|
for position, link_id in enumerate(link_ids):
|
||||||
|
conditions = [QuickLink.id == link_id]
|
||||||
|
if user_id:
|
||||||
|
from sqlalchemy import or_
|
||||||
|
conditions.append(or_(QuickLink.user_id == user_id, QuickLink.user_id.is_(None)))
|
||||||
|
|
||||||
|
stmt = update(QuickLink).where(*conditions).values(position=position)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
reordered += result.rowcount
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Reordered {reordered} quick links for user {user_id}")
|
||||||
|
return reordered
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Dashboard Widgets
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def get_widgets(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
include_defaults: bool = True,
|
||||||
|
visible_only: bool = True,
|
||||||
|
) -> List[DashboardWidget]:
|
||||||
|
"""Get dashboard widgets for a user"""
|
||||||
|
conditions = []
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
if include_defaults:
|
||||||
|
from sqlalchemy import or_
|
||||||
|
conditions.append(or_(DashboardWidget.user_id == user_id, DashboardWidget.user_id.is_(None)))
|
||||||
|
else:
|
||||||
|
conditions.append(DashboardWidget.user_id == user_id)
|
||||||
|
else:
|
||||||
|
conditions.append(DashboardWidget.user_id.is_(None))
|
||||||
|
|
||||||
|
if visible_only:
|
||||||
|
conditions.append(DashboardWidget.is_visible == True)
|
||||||
|
|
||||||
|
stmt = select(DashboardWidget).where(*conditions).order_by(
|
||||||
|
DashboardWidget.position_y, DashboardWidget.position_x
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def get_widget(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
widget_id: int,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> Optional[DashboardWidget]:
|
||||||
|
"""Get a specific widget by ID"""
|
||||||
|
conditions = [DashboardWidget.id == widget_id]
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
from sqlalchemy import or_
|
||||||
|
conditions.append(or_(DashboardWidget.user_id == user_id, DashboardWidget.user_id.is_(None)))
|
||||||
|
|
||||||
|
stmt = select(DashboardWidget).where(*conditions)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def create_widget(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
data: DashboardWidgetCreate,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> DashboardWidget:
|
||||||
|
"""Create a new dashboard widget"""
|
||||||
|
widget = DashboardWidget(
|
||||||
|
widget_type=data.widget_type,
|
||||||
|
position_x=data.position_x,
|
||||||
|
position_y=data.position_y,
|
||||||
|
width=data.width,
|
||||||
|
height=data.height,
|
||||||
|
config=data.config,
|
||||||
|
is_visible=data.is_visible,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
session.add(widget)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(widget)
|
||||||
|
|
||||||
|
logger.info(f"Created widget: {widget.widget_type} (id={widget.id}, user={user_id})")
|
||||||
|
return widget
|
||||||
|
|
||||||
|
async def update_widget(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
widget_id: int,
|
||||||
|
data: DashboardWidgetUpdate,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> Optional[DashboardWidget]:
|
||||||
|
"""Update a dashboard widget"""
|
||||||
|
widget = await self.get_widget(session, widget_id, user_id)
|
||||||
|
if not widget:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if widget.user_id and widget.user_id != user_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(widget, field, value)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(widget)
|
||||||
|
|
||||||
|
logger.info(f"Updated widget: id={widget.id}")
|
||||||
|
return widget
|
||||||
|
|
||||||
|
async def delete_widget(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
widget_id: int,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Delete a dashboard widget"""
|
||||||
|
widget = await self.get_widget(session, widget_id, user_id)
|
||||||
|
if not widget:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if widget.user_id and widget.user_id != user_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
await session.delete(widget)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Deleted widget: id={widget_id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
_dashboard_service: Optional[DashboardService] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_dashboard_service() -> DashboardService:
|
||||||
|
"""Get singleton dashboard service instance"""
|
||||||
|
global _dashboard_service
|
||||||
|
if _dashboard_service is None:
|
||||||
|
_dashboard_service = DashboardService()
|
||||||
|
return _dashboard_service
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""
|
||||||
|
Health Domain
|
||||||
|
|
||||||
|
Provides health check and diagnostics endpoints.
|
||||||
|
"""
|
||||||
|
from src.domains.health.controller import health_controller
|
||||||
|
|
||||||
|
__all__ = ["health_controller"]
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""
|
||||||
|
Health Controller
|
||||||
|
|
||||||
|
Provides service health and information endpoints
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Response
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from src.shared.base import BaseController
|
||||||
|
from src.shared.config import get_settings
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
from src.shared.database import get_database
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class HealthController(BaseController):
|
||||||
|
"""
|
||||||
|
Controller for service health and information
|
||||||
|
|
||||||
|
Provides endpoints for:
|
||||||
|
- Service information and status
|
||||||
|
- Health checks
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(prefix="", tags=["Health"])
|
||||||
|
|
||||||
|
def create_router(self) -> APIRouter:
|
||||||
|
"""Create and configure the router"""
|
||||||
|
router = APIRouter(tags=self.tags)
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/",
|
||||||
|
summary="Service information",
|
||||||
|
response_class=JSONResponse
|
||||||
|
)
|
||||||
|
async def root():
|
||||||
|
"""
|
||||||
|
Get service information and health status
|
||||||
|
|
||||||
|
Returns basic information about the API service and available endpoints.
|
||||||
|
"""
|
||||||
|
logger.debug("Root endpoint accessed")
|
||||||
|
return {
|
||||||
|
"service": settings.app_name,
|
||||||
|
"version": settings.app_version,
|
||||||
|
"status": "healthy",
|
||||||
|
"docs": "/docs"
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/health",
|
||||||
|
summary="Health check",
|
||||||
|
response_class=JSONResponse
|
||||||
|
)
|
||||||
|
async def health_check():
|
||||||
|
"""
|
||||||
|
Fast health check endpoint for container orchestration
|
||||||
|
|
||||||
|
Returns a 200 OK immediately if the service is running.
|
||||||
|
Does NOT check backend connectivity (use /health/full for that).
|
||||||
|
Used by Docker, Kubernetes, and load balancers for liveness probes.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"version": settings.app_version
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/health/full",
|
||||||
|
summary="Full health check with database",
|
||||||
|
)
|
||||||
|
async def full_health_check(response: Response):
|
||||||
|
"""
|
||||||
|
Health check including database connectivity.
|
||||||
|
|
||||||
|
Returns 200 OK if database is available, otherwise 503.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# Check database connection
|
||||||
|
database = get_database()
|
||||||
|
db_healthy = False
|
||||||
|
db_error = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
db_healthy = await database.health_check()
|
||||||
|
except Exception as e:
|
||||||
|
db_error = str(e)
|
||||||
|
logger.warning(f"Database health check failed: {db_error}")
|
||||||
|
|
||||||
|
elapsed_ms = int((time.time() - start_time) * 1000)
|
||||||
|
status_code = 200 if db_healthy else 503
|
||||||
|
response.status_code = status_code
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "healthy" if db_healthy else "unhealthy",
|
||||||
|
"status_code": status_code,
|
||||||
|
"response_time_ms": elapsed_ms,
|
||||||
|
"components": {
|
||||||
|
"database": {
|
||||||
|
"status": "healthy" if db_healthy else "unhealthy",
|
||||||
|
"error": db_error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/health/diagnostics",
|
||||||
|
summary="Detailed system diagnostics",
|
||||||
|
)
|
||||||
|
async def diagnostics():
|
||||||
|
"""
|
||||||
|
System diagnostics with service information.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
diagnostics = {
|
||||||
|
"timestamp": time.time(),
|
||||||
|
"service": {
|
||||||
|
"name": settings.app_name,
|
||||||
|
"version": settings.app_version,
|
||||||
|
"purpose": "Infrastructure management and tools API"
|
||||||
|
},
|
||||||
|
"configuration": {
|
||||||
|
"cors_origins": settings.cors_origins[:2] if len(settings.cors_origins) > 2 else settings.cors_origins
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed_ms = int((time.time() - start_time) * 1000)
|
||||||
|
diagnostics["response_time_ms"] = elapsed_ms
|
||||||
|
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
# Create controller instance
|
||||||
|
health_controller = HealthController()
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""
|
||||||
|
Housekeeping Domain
|
||||||
|
|
||||||
|
Provides home automation endpoints via Home Assistant.
|
||||||
|
"""
|
||||||
|
from src.domains.housekeeping.controller import housekeeping_controller
|
||||||
|
|
||||||
|
__all__ = ["housekeeping_controller"]
|
||||||
@@ -0,0 +1,656 @@
|
|||||||
|
"""
|
||||||
|
Housekeeping Controller
|
||||||
|
|
||||||
|
Provides API endpoints for home automation via Home Assistant.
|
||||||
|
Designed for the Tatlock Housekeeper agent and other consumers.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from fastapi import APIRouter, HTTPException, Query, Depends
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from src.shared.base import BaseController
|
||||||
|
from src.shared.clients import get_homeassistant_client
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
from src.domains.auth.oidc import get_admin_user
|
||||||
|
from src.domains.auth.callers import Caller, require_caller
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# api.schweitz.net carries no forward-auth, so these reads were otherwise
|
||||||
|
# open to the internet (T-1 / D-68). Writes are untouched — see controller
|
||||||
|
# comments at each `get_admin_user` dependency below.
|
||||||
|
require_housekeeping_reader = require_caller("office", "tatlock", "user")
|
||||||
|
|
||||||
|
|
||||||
|
# Pydantic Schemas
|
||||||
|
class Device(BaseModel):
|
||||||
|
"""Device/entity information"""
|
||||||
|
entity_id: str
|
||||||
|
name: str
|
||||||
|
domain: str
|
||||||
|
area: Optional[str] = None
|
||||||
|
state: str
|
||||||
|
attributes: Dict[str, Any] = {}
|
||||||
|
last_changed: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceListResponse(BaseModel):
|
||||||
|
"""Response for device listing"""
|
||||||
|
devices: List[Device]
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceDetailResponse(Device):
|
||||||
|
"""Detailed device response"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Area(BaseModel):
|
||||||
|
"""Area/room information"""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class AreaListResponse(BaseModel):
|
||||||
|
"""Response for area listing"""
|
||||||
|
areas: List[Area]
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceControlRequest(BaseModel):
|
||||||
|
"""Request to control a device"""
|
||||||
|
action: str = Field(..., description="Action: turn_on, turn_off, or toggle")
|
||||||
|
brightness: Optional[int] = Field(None, ge=0, le=255)
|
||||||
|
color_temp: Optional[int] = None
|
||||||
|
rgb_color: Optional[List[int]] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
extra = "allow"
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceControlResponse(BaseModel):
|
||||||
|
"""Response from device control"""
|
||||||
|
success: bool
|
||||||
|
entity_id: str
|
||||||
|
new_state: Optional[str] = None
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class Scene(BaseModel):
|
||||||
|
"""Scene information"""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class SceneListResponse(BaseModel):
|
||||||
|
"""Response for scene listing"""
|
||||||
|
scenes: List[Scene]
|
||||||
|
|
||||||
|
|
||||||
|
class SceneActivateResponse(BaseModel):
|
||||||
|
"""Response from scene activation"""
|
||||||
|
success: bool
|
||||||
|
scene_id: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class Script(BaseModel):
|
||||||
|
"""Script information"""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptListResponse(BaseModel):
|
||||||
|
"""Response for script listing"""
|
||||||
|
scripts: List[Script]
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptRunRequest(BaseModel):
|
||||||
|
"""Request to run a script"""
|
||||||
|
variables: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptRunResponse(BaseModel):
|
||||||
|
"""Response from script execution"""
|
||||||
|
success: bool
|
||||||
|
script_id: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class Automation(BaseModel):
|
||||||
|
"""Automation information"""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationListResponse(BaseModel):
|
||||||
|
"""Response for automation listing"""
|
||||||
|
automations: List[Automation]
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationToggleRequest(BaseModel):
|
||||||
|
"""Request to toggle automation"""
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationToggleResponse(BaseModel):
|
||||||
|
"""Response from automation toggle"""
|
||||||
|
success: bool
|
||||||
|
automation_id: str
|
||||||
|
enabled: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryEntry(BaseModel):
|
||||||
|
"""Single history entry"""
|
||||||
|
state: str
|
||||||
|
timestamp: str
|
||||||
|
attributes: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryResponse(BaseModel):
|
||||||
|
"""Response for history query"""
|
||||||
|
entity_id: str
|
||||||
|
history: List[HistoryEntry]
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
"""Health check response"""
|
||||||
|
status: str
|
||||||
|
connected: bool
|
||||||
|
platform: str
|
||||||
|
version: Optional[str] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorResponse(BaseModel):
|
||||||
|
"""Standard error response"""
|
||||||
|
error: bool = True
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class HousekeepingController(BaseController):
|
||||||
|
"""
|
||||||
|
Controller for home automation operations
|
||||||
|
|
||||||
|
Provides endpoints for:
|
||||||
|
- Device discovery and control
|
||||||
|
- Scene activation
|
||||||
|
- Script execution
|
||||||
|
- Automation management
|
||||||
|
- State history
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(prefix="/housekeeping", tags=["Housekeeping"])
|
||||||
|
|
||||||
|
def create_router(self) -> APIRouter:
|
||||||
|
"""Create and configure the router"""
|
||||||
|
router = APIRouter(prefix=self.prefix, tags=self.tags)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/health",
|
||||||
|
response_model=HealthResponse,
|
||||||
|
summary="Home automation health check"
|
||||||
|
)
|
||||||
|
async def get_health(caller: Caller = Depends(require_housekeeping_reader)):
|
||||||
|
"""Check Home Assistant connection health"""
|
||||||
|
ha = get_homeassistant_client()
|
||||||
|
return await ha.health_check()
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/devices",
|
||||||
|
response_model=DeviceListResponse,
|
||||||
|
summary="List available devices"
|
||||||
|
)
|
||||||
|
async def list_devices(
|
||||||
|
domain: Optional[str] = Query(None, description="Filter by domain (light, switch, climate, etc.)"),
|
||||||
|
area: Optional[str] = Query(None, description="Filter by area/room name"),
|
||||||
|
caller: Caller = Depends(require_housekeeping_reader),
|
||||||
|
):
|
||||||
|
"""List all available devices with optional filtering"""
|
||||||
|
ha = get_homeassistant_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
states = await ha.get_states()
|
||||||
|
|
||||||
|
excluded_domains = {
|
||||||
|
"zone", "person", "device_tracker", "sun", "weather",
|
||||||
|
"persistent_notification", "update", "binary_sensor", "sensor",
|
||||||
|
"conversation", "calendar", "button", "number", "select",
|
||||||
|
"text", "time", "date", "datetime", "image", "tts", "stt"
|
||||||
|
}
|
||||||
|
|
||||||
|
devices = []
|
||||||
|
for state in states:
|
||||||
|
entity_id = state.get("entity_id", "")
|
||||||
|
entity_domain = entity_id.split(".")[0] if "." in entity_id else ""
|
||||||
|
|
||||||
|
if entity_domain in excluded_domains:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if domain and entity_domain != domain:
|
||||||
|
continue
|
||||||
|
|
||||||
|
device_area = state.get("attributes", {}).get("area_id")
|
||||||
|
|
||||||
|
if area and device_area and area.lower() not in device_area.lower():
|
||||||
|
continue
|
||||||
|
|
||||||
|
device = Device(
|
||||||
|
entity_id=entity_id,
|
||||||
|
name=state.get("attributes", {}).get("friendly_name", entity_id),
|
||||||
|
domain=entity_domain,
|
||||||
|
area=device_area,
|
||||||
|
state=state.get("state", "unknown"),
|
||||||
|
attributes=state.get("attributes", {}),
|
||||||
|
last_changed=state.get("last_changed")
|
||||||
|
)
|
||||||
|
devices.append(device)
|
||||||
|
|
||||||
|
return DeviceListResponse(devices=devices)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list devices: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/devices/{entity_id:path}",
|
||||||
|
response_model=DeviceDetailResponse,
|
||||||
|
responses={404: {"model": ErrorResponse}}
|
||||||
|
)
|
||||||
|
async def get_device(
|
||||||
|
entity_id: str,
|
||||||
|
caller: Caller = Depends(require_housekeeping_reader),
|
||||||
|
):
|
||||||
|
"""Get detailed state of a specific device"""
|
||||||
|
ha = get_homeassistant_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
state = await ha.get_state(entity_id)
|
||||||
|
|
||||||
|
if not state:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail={"error": True, "code": "DEVICE_NOT_FOUND",
|
||||||
|
"message": f"Device {entity_id} not found"}
|
||||||
|
)
|
||||||
|
|
||||||
|
entity_domain = entity_id.split(".")[0] if "." in entity_id else ""
|
||||||
|
|
||||||
|
return DeviceDetailResponse(
|
||||||
|
entity_id=entity_id,
|
||||||
|
name=state.get("attributes", {}).get("friendly_name", entity_id),
|
||||||
|
domain=entity_domain,
|
||||||
|
area=state.get("attributes", {}).get("area_id"),
|
||||||
|
state=state.get("state", "unknown"),
|
||||||
|
attributes=state.get("attributes", {}),
|
||||||
|
last_changed=state.get("last_changed")
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get device {entity_id}: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/areas",
|
||||||
|
response_model=AreaListResponse,
|
||||||
|
summary="List areas/rooms"
|
||||||
|
)
|
||||||
|
async def list_areas(caller: Caller = Depends(require_housekeeping_reader)):
|
||||||
|
"""List all configured areas/rooms in Home Assistant"""
|
||||||
|
ha = get_homeassistant_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
areas = await ha.get_areas()
|
||||||
|
return AreaListResponse(
|
||||||
|
areas=[Area(id=a["id"], name=a["name"]) for a in areas]
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list areas: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/devices/{entity_id:path}/control",
|
||||||
|
response_model=DeviceControlResponse,
|
||||||
|
responses={404: {"model": ErrorResponse}, 400: {"model": ErrorResponse}}
|
||||||
|
)
|
||||||
|
async def control_device(
|
||||||
|
entity_id: str,
|
||||||
|
request: DeviceControlRequest,
|
||||||
|
user: Dict = Depends(get_admin_user)
|
||||||
|
):
|
||||||
|
"""Control a device (turn on, turn off, toggle, or set attributes)"""
|
||||||
|
ha = get_homeassistant_client()
|
||||||
|
|
||||||
|
valid_actions = ["turn_on", "turn_off", "toggle"]
|
||||||
|
if request.action not in valid_actions:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={"error": True, "code": "INVALID_ACTION",
|
||||||
|
"message": f"Invalid action '{request.action}'. Must be one of: {', '.join(valid_actions)}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_state = await ha.get_state(entity_id)
|
||||||
|
if not current_state:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail={"error": True, "code": "DEVICE_NOT_FOUND",
|
||||||
|
"message": f"Device {entity_id} not found"}
|
||||||
|
)
|
||||||
|
|
||||||
|
attributes = {}
|
||||||
|
if request.brightness is not None:
|
||||||
|
attributes["brightness"] = request.brightness
|
||||||
|
if request.color_temp is not None:
|
||||||
|
attributes["color_temp"] = request.color_temp
|
||||||
|
if request.rgb_color is not None:
|
||||||
|
attributes["rgb_color"] = request.rgb_color
|
||||||
|
|
||||||
|
extra_fields = request.model_dump(exclude={"action", "brightness", "color_temp", "rgb_color"})
|
||||||
|
for key, value in extra_fields.items():
|
||||||
|
if value is not None:
|
||||||
|
attributes[key] = value
|
||||||
|
|
||||||
|
if request.action == "turn_on":
|
||||||
|
await ha.turn_on(entity_id, **attributes)
|
||||||
|
elif request.action == "turn_off":
|
||||||
|
await ha.turn_off(entity_id)
|
||||||
|
else:
|
||||||
|
await ha.toggle(entity_id)
|
||||||
|
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
new_state = await ha.get_state(entity_id)
|
||||||
|
|
||||||
|
logger.info(f"Device {entity_id} controlled: {request.action} by {user.get('preferred_username', 'unknown')}")
|
||||||
|
|
||||||
|
return DeviceControlResponse(
|
||||||
|
success=True,
|
||||||
|
entity_id=entity_id,
|
||||||
|
new_state=new_state.get("state") if new_state else None,
|
||||||
|
message=f"Device {request.action} successful"
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to control device {entity_id}: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/scenes",
|
||||||
|
response_model=SceneListResponse,
|
||||||
|
summary="List available scenes"
|
||||||
|
)
|
||||||
|
async def list_scenes(caller: Caller = Depends(require_housekeeping_reader)):
|
||||||
|
"""List all available scenes in Home Assistant"""
|
||||||
|
ha = get_homeassistant_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
states = await ha.get_states()
|
||||||
|
|
||||||
|
scenes = [
|
||||||
|
Scene(
|
||||||
|
id=s["entity_id"],
|
||||||
|
name=s.get("attributes", {}).get("friendly_name", s["entity_id"])
|
||||||
|
)
|
||||||
|
for s in states
|
||||||
|
if s["entity_id"].startswith("scene.")
|
||||||
|
]
|
||||||
|
|
||||||
|
return SceneListResponse(scenes=scenes)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list scenes: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/scenes/{scene_id:path}/activate",
|
||||||
|
response_model=SceneActivateResponse,
|
||||||
|
responses={404: {"model": ErrorResponse}}
|
||||||
|
)
|
||||||
|
async def activate_scene(
|
||||||
|
scene_id: str,
|
||||||
|
user: Dict = Depends(get_admin_user)
|
||||||
|
):
|
||||||
|
"""Activate a scene"""
|
||||||
|
ha = get_homeassistant_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
state = await ha.get_state(scene_id)
|
||||||
|
if not state:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail={"error": True, "code": "SCENE_NOT_FOUND",
|
||||||
|
"message": f"Scene {scene_id} not found"}
|
||||||
|
)
|
||||||
|
|
||||||
|
await ha.activate_scene(scene_id)
|
||||||
|
|
||||||
|
logger.info(f"Scene {scene_id} activated by {user.get('preferred_username', 'unknown')}")
|
||||||
|
|
||||||
|
return SceneActivateResponse(
|
||||||
|
success=True,
|
||||||
|
scene_id=scene_id,
|
||||||
|
message="Scene activated"
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to activate scene {scene_id}: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/scripts",
|
||||||
|
response_model=ScriptListResponse,
|
||||||
|
summary="List available scripts"
|
||||||
|
)
|
||||||
|
async def list_scripts(caller: Caller = Depends(require_housekeeping_reader)):
|
||||||
|
"""List all available scripts/sequences in Home Assistant"""
|
||||||
|
ha = get_homeassistant_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
states = await ha.get_states()
|
||||||
|
|
||||||
|
scripts = [
|
||||||
|
Script(
|
||||||
|
id=s["entity_id"],
|
||||||
|
name=s.get("attributes", {}).get("friendly_name", s["entity_id"])
|
||||||
|
)
|
||||||
|
for s in states
|
||||||
|
if s["entity_id"].startswith("script.")
|
||||||
|
]
|
||||||
|
|
||||||
|
return ScriptListResponse(scripts=scripts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list scripts: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/scripts/{script_id:path}/run",
|
||||||
|
response_model=ScriptRunResponse,
|
||||||
|
responses={404: {"model": ErrorResponse}}
|
||||||
|
)
|
||||||
|
async def run_script(
|
||||||
|
script_id: str,
|
||||||
|
request: Optional[ScriptRunRequest] = None,
|
||||||
|
user: Dict = Depends(get_admin_user)
|
||||||
|
):
|
||||||
|
"""Execute a script with optional variables"""
|
||||||
|
ha = get_homeassistant_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
state = await ha.get_state(script_id)
|
||||||
|
if not state:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail={"error": True, "code": "SCRIPT_NOT_FOUND",
|
||||||
|
"message": f"Script {script_id} not found"}
|
||||||
|
)
|
||||||
|
|
||||||
|
variables = request.variables if request else None
|
||||||
|
await ha.run_script(script_id, variables)
|
||||||
|
|
||||||
|
logger.info(f"Script {script_id} executed by {user.get('preferred_username', 'unknown')}")
|
||||||
|
|
||||||
|
return ScriptRunResponse(
|
||||||
|
success=True,
|
||||||
|
script_id=script_id,
|
||||||
|
message="Script executed"
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to run script {script_id}: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/automations",
|
||||||
|
response_model=AutomationListResponse,
|
||||||
|
summary="List automations"
|
||||||
|
)
|
||||||
|
async def list_automations(caller: Caller = Depends(require_housekeeping_reader)):
|
||||||
|
"""List all automations with their enabled/disabled status"""
|
||||||
|
ha = get_homeassistant_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
states = await ha.get_states()
|
||||||
|
|
||||||
|
automations = [
|
||||||
|
Automation(
|
||||||
|
id=s["entity_id"],
|
||||||
|
name=s.get("attributes", {}).get("friendly_name", s["entity_id"]),
|
||||||
|
enabled=s.get("state") == "on"
|
||||||
|
)
|
||||||
|
for s in states
|
||||||
|
if s["entity_id"].startswith("automation.")
|
||||||
|
]
|
||||||
|
|
||||||
|
return AutomationListResponse(automations=automations)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list automations: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/automations/{automation_id:path}/toggle",
|
||||||
|
response_model=AutomationToggleResponse,
|
||||||
|
responses={404: {"model": ErrorResponse}}
|
||||||
|
)
|
||||||
|
async def toggle_automation(
|
||||||
|
automation_id: str,
|
||||||
|
request: AutomationToggleRequest,
|
||||||
|
user: Dict = Depends(get_admin_user)
|
||||||
|
):
|
||||||
|
"""Enable or disable an automation"""
|
||||||
|
ha = get_homeassistant_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
state = await ha.get_state(automation_id)
|
||||||
|
if not state:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail={"error": True, "code": "AUTOMATION_NOT_FOUND",
|
||||||
|
"message": f"Automation {automation_id} not found"}
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.enabled:
|
||||||
|
await ha.enable_automation(automation_id)
|
||||||
|
else:
|
||||||
|
await ha.disable_automation(automation_id)
|
||||||
|
|
||||||
|
logger.info(f"Automation {automation_id} {'enabled' if request.enabled else 'disabled'} by {user.get('preferred_username', 'unknown')}")
|
||||||
|
|
||||||
|
return AutomationToggleResponse(
|
||||||
|
success=True,
|
||||||
|
automation_id=automation_id,
|
||||||
|
enabled=request.enabled,
|
||||||
|
message=f"Automation {'enabled' if request.enabled else 'disabled'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to toggle automation {automation_id}: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/history",
|
||||||
|
response_model=HistoryResponse,
|
||||||
|
responses={400: {"model": ErrorResponse}}
|
||||||
|
)
|
||||||
|
async def get_history(
|
||||||
|
entity_id: str = Query(..., description="Entity ID to get history for"),
|
||||||
|
hours: int = Query(24, ge=1, le=168, description="Hours of history (1-168)"),
|
||||||
|
caller: Caller = Depends(require_housekeeping_reader),
|
||||||
|
):
|
||||||
|
"""Get state history for a device"""
|
||||||
|
ha = get_homeassistant_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
history_data = await ha.get_history(entity_id, hours)
|
||||||
|
|
||||||
|
history_entries = []
|
||||||
|
if history_data and len(history_data) > 0:
|
||||||
|
for entry in history_data[0]:
|
||||||
|
history_entries.append(HistoryEntry(
|
||||||
|
state=entry.get("state", "unknown"),
|
||||||
|
timestamp=entry.get("last_changed", ""),
|
||||||
|
attributes=entry.get("attributes", {})
|
||||||
|
))
|
||||||
|
|
||||||
|
return HistoryResponse(
|
||||||
|
entity_id=entity_id,
|
||||||
|
history=history_entries
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get history for {entity_id}: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
# Create controller instance
|
||||||
|
housekeeping_controller = HousekeepingController()
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""
|
||||||
|
Infrastructure Domain
|
||||||
|
|
||||||
|
Provides infrastructure management endpoints for Docker/Portainer and NPM.
|
||||||
|
"""
|
||||||
|
from src.domains.infrastructure.controller import infrastructure_controller
|
||||||
|
|
||||||
|
__all__ = ["infrastructure_controller"]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
|||||||
|
"""
|
||||||
|
Scribe Domain
|
||||||
|
|
||||||
|
The household's message bus (workspace D-66, D-68, D-73): addresses, send,
|
||||||
|
mailbox, history, find, mark, tag. Served under /scribe/*.
|
||||||
|
"""
|
||||||
|
from src.domains.scribe.controller import scribe_controller
|
||||||
|
|
||||||
|
__all__ = ["scribe_controller"]
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"""
|
||||||
|
Scribe Controller
|
||||||
|
|
||||||
|
The Scribe's messaging verbs (dossier core-api T-5): register, list
|
||||||
|
(addresses), send, mailbox, history, find, mark, tag/untag. Every route is
|
||||||
|
behind an Office or user caller (T-1/T-4); every query filters on the
|
||||||
|
caller's tenant (D-73) — an address or message in another tenant answers
|
||||||
|
exactly like one that does not exist.
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
|
from src.domains.auth.callers import Caller, require_caller
|
||||||
|
from src.domains.scribe.schemas import (
|
||||||
|
AddressListResponse,
|
||||||
|
AddressRegisterRequest,
|
||||||
|
AddressResponse,
|
||||||
|
HistoryResponse,
|
||||||
|
MailboxEntryResponse,
|
||||||
|
MailboxListResponse,
|
||||||
|
MailboxPatchRequest,
|
||||||
|
MessageResponse,
|
||||||
|
MessageSendRequest,
|
||||||
|
)
|
||||||
|
from src.domains.scribe.service import ScribeError, get_scribe_service
|
||||||
|
from src.shared.base import BaseController
|
||||||
|
from src.shared.database import get_async_session
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
require_scribe_reader = require_caller("office", "user")
|
||||||
|
require_scribe_writer = require_caller("office", "user", identity_required=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _address_response(address, owned: bool) -> AddressResponse:
|
||||||
|
return AddressResponse(
|
||||||
|
name=address.name,
|
||||||
|
owner_consumer=address.owner_consumer,
|
||||||
|
created_by=address.created_by,
|
||||||
|
created_at=address.created_at,
|
||||||
|
owned=owned,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _message_response(message) -> MessageResponse:
|
||||||
|
return MessageResponse.model_validate(message, from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_response(entry, message) -> MailboxEntryResponse:
|
||||||
|
return MailboxEntryResponse(
|
||||||
|
id=entry.id,
|
||||||
|
address=entry.address,
|
||||||
|
read_at=entry.read_at,
|
||||||
|
tags=entry.tags,
|
||||||
|
created_at=entry.created_at,
|
||||||
|
message=_message_response(message),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScribeController(BaseController):
|
||||||
|
"""Controller for the Scribe domain."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(prefix="/scribe", tags=["Scribe"])
|
||||||
|
|
||||||
|
def create_router(self) -> APIRouter:
|
||||||
|
router = APIRouter(prefix=self.prefix, tags=self.tags)
|
||||||
|
service = get_scribe_service()
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# register / list addresses
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/addresses",
|
||||||
|
response_model=AddressResponse,
|
||||||
|
summary="Register an address (idempotent for the same consumer)",
|
||||||
|
)
|
||||||
|
async def register_address(
|
||||||
|
data: AddressRegisterRequest,
|
||||||
|
caller: Caller = Depends(require_scribe_writer),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
address, owned = await service.register_address(session, data.name, caller)
|
||||||
|
except ScribeError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=error.status_code, detail={"code": error.code, **error.extra}
|
||||||
|
) from error
|
||||||
|
return _address_response(address, owned)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/addresses",
|
||||||
|
response_model=AddressListResponse,
|
||||||
|
summary="List addresses in the caller's tenant",
|
||||||
|
)
|
||||||
|
async def list_addresses(
|
||||||
|
caller: Caller = Depends(require_scribe_reader),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
results = await service.list_addresses(session, caller)
|
||||||
|
except ScribeError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=error.status_code, detail={"code": error.code, **error.extra}
|
||||||
|
) from error
|
||||||
|
return AddressListResponse(
|
||||||
|
addresses=[_address_response(address, owned) for address, owned in results]
|
||||||
|
)
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# send
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/messages",
|
||||||
|
response_model=MessageResponse,
|
||||||
|
status_code=201,
|
||||||
|
summary="Send a message to one or more addresses",
|
||||||
|
)
|
||||||
|
async def send_message(
|
||||||
|
data: MessageSendRequest,
|
||||||
|
caller: Caller = Depends(require_scribe_writer),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
message = await service.send_message(session, data, caller)
|
||||||
|
except ScribeError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=error.status_code, detail={"code": error.code, **error.extra}
|
||||||
|
) from error
|
||||||
|
return _message_response(message)
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# find
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/messages",
|
||||||
|
response_model=MailboxListResponse,
|
||||||
|
summary="Full text search over messages in the caller identity's mailbox",
|
||||||
|
)
|
||||||
|
async def find_messages(
|
||||||
|
q: str = Query(..., min_length=1),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
caller: Caller = Depends(require_scribe_reader),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
results, total = await service.find_messages(session, caller, q, limit)
|
||||||
|
except ScribeError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=error.status_code, detail={"code": error.code, **error.extra}
|
||||||
|
) from error
|
||||||
|
return MailboxListResponse(
|
||||||
|
entries=[_entry_response(entry, message) for entry, message in results],
|
||||||
|
total=total,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# mailbox
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/mailbox",
|
||||||
|
response_model=MailboxListResponse,
|
||||||
|
summary="The caller identity's mailbox, unread first",
|
||||||
|
)
|
||||||
|
async def get_mailbox(
|
||||||
|
unread: bool = Query(False),
|
||||||
|
tag: Optional[str] = Query(None),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
before_id: Optional[int] = Query(None),
|
||||||
|
caller: Caller = Depends(require_scribe_reader),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
results, total = await service.get_mailbox(
|
||||||
|
session, caller, unread=unread, tag=tag, limit=limit, before_id=before_id
|
||||||
|
)
|
||||||
|
except ScribeError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=error.status_code, detail={"code": error.code, **error.extra}
|
||||||
|
) from error
|
||||||
|
return MailboxListResponse(
|
||||||
|
entries=[_entry_response(entry, message) for entry, message in results],
|
||||||
|
total=total,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/mailbox/{entry_id}",
|
||||||
|
response_model=MailboxEntryResponse,
|
||||||
|
summary="Mark read/unread, or tag/untag a mailbox entry",
|
||||||
|
)
|
||||||
|
async def patch_mailbox_entry(
|
||||||
|
entry_id: int,
|
||||||
|
data: MailboxPatchRequest,
|
||||||
|
caller: Caller = Depends(require_scribe_writer),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
entry, message = await service.patch_mailbox_entry(session, entry_id, data, caller)
|
||||||
|
except ScribeError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=error.status_code, detail={"code": error.code, **error.extra}
|
||||||
|
) from error
|
||||||
|
return _entry_response(entry, message)
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# history
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/addresses/{name}/history",
|
||||||
|
response_model=HistoryResponse,
|
||||||
|
summary="The retained messages sent to an address, oldest first",
|
||||||
|
)
|
||||||
|
async def address_history(
|
||||||
|
name: str,
|
||||||
|
since_id: Optional[int] = Query(None),
|
||||||
|
limit: int = Query(100, ge=1, le=500),
|
||||||
|
caller: Caller = Depends(require_scribe_reader),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
messages = await service.get_history(session, name, caller, since_id, limit)
|
||||||
|
except ScribeError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=error.status_code, detail={"code": error.code, **error.extra}
|
||||||
|
) from error
|
||||||
|
return HistoryResponse(
|
||||||
|
address=name,
|
||||||
|
messages=[_message_response(message) for message in messages],
|
||||||
|
)
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
# Create controller instance
|
||||||
|
scribe_controller = ScribeController()
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""
|
||||||
|
Scribe Domain Models
|
||||||
|
|
||||||
|
The Scribe is the household's message bus (workspace D-66, D-68, D-73): a
|
||||||
|
retained pub/sub log where an address is a topic. This round ships the
|
||||||
|
primitives migration 006 creates — addresses, messages, mailbox entries —
|
||||||
|
for direct messaging only; groups, subscriptions, the notification matrix
|
||||||
|
and delivery are later work (workspace T-176).
|
||||||
|
|
||||||
|
SQLAlchemy 2.0 Mapped/mapped_column style, timestamptz, following
|
||||||
|
src/domains/book/models.py. Every table carries `owner_tenant` and every
|
||||||
|
query in the service layer filters on it (D-73's hard tenant boundary) —
|
||||||
|
there is no database-level RLS here, so that filter is load-bearing in
|
||||||
|
Python, not just in SQL.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Computed,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Identity,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import ARRAY, TSVECTOR
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from src.shared.database import Base
|
||||||
|
|
||||||
|
# Address name shape, identical to src/domains/auth/callers.py's identity
|
||||||
|
# rule (an address is named for an identity, D-66) — not imported from there
|
||||||
|
# because that module's constants are private to it; kept here so a drift
|
||||||
|
# between the two never happens silently, the regex is asserted equal in
|
||||||
|
# tests/test_scribe.py instead.
|
||||||
|
ADDRESS_NAME_PATTERN = r"^[a-z0-9][a-z0-9-]{0,62}$"
|
||||||
|
RESERVED_ADDRESS_NAMES = {"book", "office", "scheduler", "tatlock"}
|
||||||
|
|
||||||
|
|
||||||
|
class ScribeAddress(Base):
|
||||||
|
"""
|
||||||
|
An address: a topic named for an identity. `register` creates it once
|
||||||
|
(D-66) — the pair (owner_tenant, name) is the natural key, so no
|
||||||
|
surrogate id: no route ever references an address except by name.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "scribe_addresses"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("owner_tenant", "name", name="uq_scribe_addresses_tenant_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# FK to tenants.id added by migration 007 (core-api T-8).
|
||||||
|
owner_tenant: Mapped[str] = mapped_column(
|
||||||
|
String(63), ForeignKey("tenants.id", ondelete="RESTRICT"), primary_key=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(63), primary_key=True)
|
||||||
|
# Nullable: a "user"-kind caller (direct OIDC, not through the Office)
|
||||||
|
# carries no consumer (src/domains/auth/callers.py), so an address it
|
||||||
|
# registers has none either.
|
||||||
|
owner_consumer: Mapped[Optional[str]] = mapped_column(String(63), nullable=True)
|
||||||
|
created_by: Mapped[str] = mapped_column(String(63), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<ScribeAddress {self.owner_tenant}/{self.name}>"
|
||||||
|
|
||||||
|
|
||||||
|
class ScribeMessage(Base):
|
||||||
|
"""
|
||||||
|
A retained message. `topics` is the set of addresses (and, later,
|
||||||
|
groups) it was published to — an integer array is denormalized from
|
||||||
|
the mailbox entries so `history` can read the retained log directly
|
||||||
|
without joining through them (D-66: "topics are retained and
|
||||||
|
re-readable"). Nothing deletes a message.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "scribe_messages"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, Identity(always=False), primary_key=True)
|
||||||
|
# FK to tenants.id added by migration 007 (core-api T-8).
|
||||||
|
owner_tenant: Mapped[str] = mapped_column(
|
||||||
|
String(63), ForeignKey("tenants.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
topics: Mapped[List[str]] = mapped_column(ARRAY(Text), nullable=False, default=list)
|
||||||
|
from_identity: Mapped[str] = mapped_column(String(63), nullable=False)
|
||||||
|
from_consumer: Mapped[Optional[str]] = mapped_column(String(63), nullable=True)
|
||||||
|
subject: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
tags: Mapped[List[str]] = mapped_column(ARRAY(Text), nullable=False, default=list)
|
||||||
|
reply_to: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("scribe_messages.id"), nullable=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
# Generated by migration 006 (`to_tsvector('simple', ...)`, GIN-indexed),
|
||||||
|
# 'simple' for the same reason as book_records.search: mixed Dutch/
|
||||||
|
# English content. `Computed` mirrors the migration's SQL.
|
||||||
|
search: Mapped[Optional[str]] = mapped_column(
|
||||||
|
TSVECTOR,
|
||||||
|
Computed("to_tsvector('simple', coalesce(subject, '') || ' ' || body)", persisted=True),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<ScribeMessage {self.id} from={self.from_identity}>"
|
||||||
|
|
||||||
|
|
||||||
|
class ScribeMailboxEntry(Base):
|
||||||
|
"""One row per recipient address per message — what lands in a mailbox."""
|
||||||
|
|
||||||
|
__tablename__ = "scribe_mailbox_entries"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("address", "message_id", name="uq_scribe_mailbox_entries_address_message"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, Identity(always=False), primary_key=True)
|
||||||
|
address: Mapped[str] = mapped_column(String(63), nullable=False)
|
||||||
|
# FK to tenants.id added by migration 007 (core-api T-8).
|
||||||
|
owner_tenant: Mapped[str] = mapped_column(
|
||||||
|
String(63), ForeignKey("tenants.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
message_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("scribe_messages.id"), nullable=False
|
||||||
|
)
|
||||||
|
read_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
tags: Mapped[List[str]] = mapped_column(ARRAY(Text), nullable=False, default=list)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<ScribeMailboxEntry {self.id} address={self.address} message={self.message_id}>"
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""
|
||||||
|
Scribe Domain Schemas
|
||||||
|
|
||||||
|
Pydantic request/response payloads for the Scribe (D-66, D-68, D-73). This
|
||||||
|
round ships `register`, `list` (addresses), `send`, `mailbox`, `history`,
|
||||||
|
`find`, `mark`, `tag`/`untag` — groups, subscriptions, the notification
|
||||||
|
matrix and delivery are later work (dossier core-api T-5).
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from src.domains.scribe.models import ADDRESS_NAME_PATTERN
|
||||||
|
from src.shared.base import BaseSchema
|
||||||
|
|
||||||
|
|
||||||
|
class AddressRegisterRequest(BaseSchema):
|
||||||
|
"""`register` — the name only; owner (tenant, consumer) comes from the caller."""
|
||||||
|
|
||||||
|
name: str = Field(..., pattern=ADDRESS_NAME_PATTERN)
|
||||||
|
|
||||||
|
|
||||||
|
class AddressResponse(BaseSchema):
|
||||||
|
name: str
|
||||||
|
owner_consumer: Optional[str]
|
||||||
|
created_by: str
|
||||||
|
created_at: datetime
|
||||||
|
owned: bool = Field(..., description="True when the caller's consumer registered this address")
|
||||||
|
|
||||||
|
|
||||||
|
class AddressListResponse(BaseSchema):
|
||||||
|
addresses: List[AddressResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class MessageSendRequest(BaseSchema):
|
||||||
|
"""`send` — `from` is never in the body; it is the caller's own identity."""
|
||||||
|
|
||||||
|
to: List[str] = Field(..., min_length=1)
|
||||||
|
subject: Optional[str] = Field(None, max_length=500)
|
||||||
|
body: str = Field(..., min_length=1)
|
||||||
|
tags: List[str] = Field(default_factory=list)
|
||||||
|
reply_to: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MessageResponse(BaseSchema):
|
||||||
|
id: int
|
||||||
|
topics: List[str]
|
||||||
|
from_identity: str
|
||||||
|
from_consumer: Optional[str]
|
||||||
|
subject: Optional[str]
|
||||||
|
body: str
|
||||||
|
tags: List[str]
|
||||||
|
reply_to: Optional[int]
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class MailboxEntryResponse(BaseSchema):
|
||||||
|
"""`mailbox` / `find` rows, and the result of `mark`/`tag`/`untag`."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
address: str
|
||||||
|
read_at: Optional[datetime]
|
||||||
|
tags: List[str]
|
||||||
|
created_at: datetime
|
||||||
|
message: MessageResponse
|
||||||
|
|
||||||
|
|
||||||
|
class MailboxListResponse(BaseSchema):
|
||||||
|
entries: List[MailboxEntryResponse]
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryResponse(BaseSchema):
|
||||||
|
address: str
|
||||||
|
messages: List[MessageResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class MailboxPatchRequest(BaseSchema):
|
||||||
|
"""`mark` (`read`) and `tag`/`untag` (`add_tags`/`remove_tags`) share one route."""
|
||||||
|
|
||||||
|
read: Optional[bool] = None
|
||||||
|
add_tags: Optional[List[str]] = None
|
||||||
|
remove_tags: Optional[List[str]] = None
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
"""
|
||||||
|
Scribe Domain Service
|
||||||
|
|
||||||
|
Business logic for the Scribe — the household's message bus (D-66, D-68,
|
||||||
|
D-73). Every method starts by requiring a tenant on the caller and ends by
|
||||||
|
filtering its query on it: **an address or message in another tenant
|
||||||
|
answers exactly like one that does not exist** (dossier core-api T-5), so
|
||||||
|
no method here ever confirms an id across that boundary.
|
||||||
|
|
||||||
|
Methods take `session: AsyncSession` per call rather than storing it on
|
||||||
|
`self`, following src/domains/book/service.py.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.domains.auth.callers import Caller
|
||||||
|
from src.domains.scribe.models import (
|
||||||
|
RESERVED_ADDRESS_NAMES,
|
||||||
|
ScribeAddress,
|
||||||
|
ScribeMailboxEntry,
|
||||||
|
ScribeMessage,
|
||||||
|
)
|
||||||
|
from src.domains.scribe.schemas import MailboxPatchRequest, MessageSendRequest
|
||||||
|
from src.domains.tenants.service import TenantError, get_tenants_service
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ScribeError(Exception):
|
||||||
|
"""Raised for a Scribe-domain failure; the controller maps it to a response."""
|
||||||
|
|
||||||
|
def __init__(self, code: str, status_code: int = 400, **extra):
|
||||||
|
self.code = code
|
||||||
|
self.status_code = status_code
|
||||||
|
self.extra = extra
|
||||||
|
super().__init__(code)
|
||||||
|
|
||||||
|
|
||||||
|
class ScribeService:
|
||||||
|
"""Service for Scribe operations."""
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Internal helpers
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def _require_tenant(self, session: AsyncSession, caller: Caller) -> str:
|
||||||
|
"""Every Scribe operation needs a tenant; a caller without one gets
|
||||||
|
a clear error rather than a query that silently matches nothing.
|
||||||
|
|
||||||
|
Delegates to the tenant registry's one enforcement point (core-api
|
||||||
|
T-8 / workspace D-73), which additionally refuses an unknown
|
||||||
|
tenant, a suspended one, or an Office consumer with no grant in it.
|
||||||
|
`TenantError` is translated to `ScribeError` so every call site and
|
||||||
|
the controller's single `except ScribeError` keep working
|
||||||
|
unchanged."""
|
||||||
|
try:
|
||||||
|
return await get_tenants_service().require_tenant(session, caller)
|
||||||
|
except TenantError as error:
|
||||||
|
raise ScribeError(error.code, error.status_code) from error
|
||||||
|
|
||||||
|
def _validate_address_name(self, name: str) -> None:
|
||||||
|
"""Beyond the schema's regex (shape only): not `group-*`, not reserved.
|
||||||
|
Mirrors src/domains/auth/callers.py's `_validate_identity` rule —
|
||||||
|
an address is named for an identity (D-66)."""
|
||||||
|
if name.startswith("group-") or name in RESERVED_ADDRESS_NAMES:
|
||||||
|
raise ScribeError("address_invalid", 400)
|
||||||
|
|
||||||
|
async def _get_address(self, session: AsyncSession, tenant: str, name: str) -> Optional[ScribeAddress]:
|
||||||
|
return await session.get(ScribeAddress, (tenant, name))
|
||||||
|
|
||||||
|
async def _require_owned_identity(self, session: AsyncSession, caller: Caller, tenant: str) -> None:
|
||||||
|
"""mailbox/find read the caller's own identity's mailbox: the identity
|
||||||
|
must be a registered address owned by the caller's consumer, else
|
||||||
|
403 identity_not_owned — the same refusal `send` uses for `from`."""
|
||||||
|
address = await self._get_address(session, tenant, caller.identity)
|
||||||
|
if address is None or address.owner_consumer != caller.consumer:
|
||||||
|
raise ScribeError("identity_not_owned", 403)
|
||||||
|
|
||||||
|
async def _messages_for_entries(
|
||||||
|
self, session: AsyncSession, entries: List[ScribeMailboxEntry]
|
||||||
|
) -> dict:
|
||||||
|
if not entries:
|
||||||
|
return {}
|
||||||
|
ids = [entry.message_id for entry in entries]
|
||||||
|
stmt = select(ScribeMessage).where(ScribeMessage.id.in_(ids))
|
||||||
|
messages = (await session.execute(stmt)).scalars().all()
|
||||||
|
return {message.id: message for message in messages}
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# register / list addresses
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def register_address(
|
||||||
|
self, session: AsyncSession, name: str, caller: Caller
|
||||||
|
) -> Tuple[ScribeAddress, bool]:
|
||||||
|
"""`register` — idempotent for the same consumer; 409 address_taken
|
||||||
|
for another consumer in the tenant (D-66)."""
|
||||||
|
tenant = await self._require_tenant(session, caller)
|
||||||
|
self._validate_address_name(name)
|
||||||
|
|
||||||
|
existing = await self._get_address(session, tenant, name)
|
||||||
|
if existing is not None:
|
||||||
|
if existing.owner_consumer == caller.consumer:
|
||||||
|
return existing, True
|
||||||
|
raise ScribeError("address_taken", 409)
|
||||||
|
|
||||||
|
address = ScribeAddress(
|
||||||
|
owner_tenant=tenant,
|
||||||
|
name=name,
|
||||||
|
owner_consumer=caller.consumer,
|
||||||
|
created_by=caller.identity,
|
||||||
|
)
|
||||||
|
session.add(address)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(address)
|
||||||
|
|
||||||
|
logger.info(f"Scribe address registered: {tenant}/{name} by={caller.identity}")
|
||||||
|
return address, True
|
||||||
|
|
||||||
|
async def list_addresses(
|
||||||
|
self, session: AsyncSession, caller: Caller
|
||||||
|
) -> List[Tuple[ScribeAddress, bool]]:
|
||||||
|
"""`list` — the caller's tenant; mark which the caller's consumer owns."""
|
||||||
|
tenant = await self._require_tenant(session, caller)
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(ScribeAddress)
|
||||||
|
.where(ScribeAddress.owner_tenant == tenant)
|
||||||
|
.order_by(ScribeAddress.name)
|
||||||
|
)
|
||||||
|
addresses = (await session.execute(stmt)).scalars().all()
|
||||||
|
return [(a, a.owner_consumer == caller.consumer) for a in addresses]
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# send
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def send_message(
|
||||||
|
self, session: AsyncSession, data: MessageSendRequest, caller: Caller
|
||||||
|
) -> ScribeMessage:
|
||||||
|
"""`send` — `from` is the caller's own identity, never the body."""
|
||||||
|
tenant = await self._require_tenant(session, caller)
|
||||||
|
|
||||||
|
from_address = await self._get_address(session, tenant, caller.identity)
|
||||||
|
if from_address is None or from_address.owner_consumer != caller.consumer:
|
||||||
|
raise ScribeError("identity_not_owned", 403)
|
||||||
|
|
||||||
|
recipients = list(dict.fromkeys(data.to)) # de-dup, keep order
|
||||||
|
found_stmt = select(ScribeAddress.name).where(
|
||||||
|
ScribeAddress.owner_tenant == tenant, ScribeAddress.name.in_(recipients)
|
||||||
|
)
|
||||||
|
found = set((await session.execute(found_stmt)).scalars().all())
|
||||||
|
missing = [name for name in recipients if name not in found]
|
||||||
|
if missing:
|
||||||
|
raise ScribeError("unknown_address", 404, names=missing)
|
||||||
|
|
||||||
|
if data.reply_to is not None:
|
||||||
|
reply_target = await session.get(ScribeMessage, data.reply_to)
|
||||||
|
if reply_target is None or reply_target.owner_tenant != tenant:
|
||||||
|
raise ScribeError("not_found", 404)
|
||||||
|
|
||||||
|
tags = list(dict.fromkeys(data.tags))
|
||||||
|
|
||||||
|
message = ScribeMessage(
|
||||||
|
owner_tenant=tenant,
|
||||||
|
topics=recipients,
|
||||||
|
from_identity=caller.identity,
|
||||||
|
from_consumer=caller.consumer,
|
||||||
|
subject=data.subject,
|
||||||
|
body=data.body,
|
||||||
|
tags=tags,
|
||||||
|
reply_to=data.reply_to,
|
||||||
|
)
|
||||||
|
session.add(message)
|
||||||
|
await session.flush() # assign message.id
|
||||||
|
|
||||||
|
for recipient in recipients:
|
||||||
|
session.add(
|
||||||
|
ScribeMailboxEntry(address=recipient, owner_tenant=tenant, message_id=message.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(message)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Scribe message sent: id={message.id} from={caller.identity} to={recipients}"
|
||||||
|
)
|
||||||
|
return message
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# mailbox / find
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def get_mailbox(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
caller: Caller,
|
||||||
|
unread: Optional[bool] = None,
|
||||||
|
tag: Optional[str] = None,
|
||||||
|
limit: int = 50,
|
||||||
|
before_id: Optional[int] = None,
|
||||||
|
) -> Tuple[List[Tuple[ScribeMailboxEntry, ScribeMessage]], int]:
|
||||||
|
"""`mailbox` — the caller's identity's entries, unread first."""
|
||||||
|
tenant = await self._require_tenant(session, caller)
|
||||||
|
await self._require_owned_identity(session, caller, tenant)
|
||||||
|
|
||||||
|
conditions = [
|
||||||
|
ScribeMailboxEntry.owner_tenant == tenant,
|
||||||
|
ScribeMailboxEntry.address == caller.identity,
|
||||||
|
]
|
||||||
|
if unread:
|
||||||
|
conditions.append(ScribeMailboxEntry.read_at.is_(None))
|
||||||
|
if tag is not None:
|
||||||
|
conditions.append(ScribeMailboxEntry.tags.contains([tag]))
|
||||||
|
if before_id is not None:
|
||||||
|
conditions.append(ScribeMailboxEntry.id < before_id)
|
||||||
|
|
||||||
|
base_query = select(ScribeMailboxEntry).where(*conditions)
|
||||||
|
count_query = select(func.count()).select_from(base_query.subquery())
|
||||||
|
total = (await session.execute(count_query)).scalar() or 0
|
||||||
|
|
||||||
|
query = (
|
||||||
|
base_query
|
||||||
|
.order_by(ScribeMailboxEntry.read_at.is_(None).desc(), ScribeMailboxEntry.id.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
entries = list((await session.execute(query)).scalars().all())
|
||||||
|
messages_by_id = await self._messages_for_entries(session, entries)
|
||||||
|
|
||||||
|
return [(entry, messages_by_id[entry.message_id]) for entry in entries], total
|
||||||
|
|
||||||
|
async def find_messages(
|
||||||
|
self, session: AsyncSession, caller: Caller, q: str, limit: int = 50
|
||||||
|
) -> Tuple[List[Tuple[ScribeMailboxEntry, ScribeMessage]], int]:
|
||||||
|
"""`find` — full text over messages in the caller identity's mailbox."""
|
||||||
|
tenant = await self._require_tenant(session, caller)
|
||||||
|
await self._require_owned_identity(session, caller, tenant)
|
||||||
|
|
||||||
|
tsquery = func.websearch_to_tsquery("simple", q)
|
||||||
|
base_query = (
|
||||||
|
select(ScribeMailboxEntry)
|
||||||
|
.join(ScribeMessage, ScribeMailboxEntry.message_id == ScribeMessage.id)
|
||||||
|
.where(
|
||||||
|
ScribeMailboxEntry.owner_tenant == tenant,
|
||||||
|
ScribeMailboxEntry.address == caller.identity,
|
||||||
|
ScribeMessage.search.op("@@")(tsquery),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
count_query = select(func.count()).select_from(base_query.subquery())
|
||||||
|
total = (await session.execute(count_query)).scalar() or 0
|
||||||
|
|
||||||
|
query = base_query.order_by(ScribeMailboxEntry.id.desc()).limit(limit)
|
||||||
|
entries = list((await session.execute(query)).scalars().all())
|
||||||
|
messages_by_id = await self._messages_for_entries(session, entries)
|
||||||
|
|
||||||
|
return [(entry, messages_by_id[entry.message_id]) for entry in entries], total
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# history
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def get_history(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
name: str,
|
||||||
|
caller: Caller,
|
||||||
|
since_id: Optional[int] = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> List[ScribeMessage]:
|
||||||
|
"""`history` — the retained messages sent to that address, oldest
|
||||||
|
first. Reading never consumes or marks: this reads scribe_messages
|
||||||
|
directly, never scribe_mailbox_entries."""
|
||||||
|
tenant = await self._require_tenant(session, caller)
|
||||||
|
|
||||||
|
address = await self._get_address(session, tenant, name)
|
||||||
|
if address is None:
|
||||||
|
raise ScribeError("unknown_address", 404)
|
||||||
|
|
||||||
|
conditions = [
|
||||||
|
ScribeMessage.owner_tenant == tenant,
|
||||||
|
ScribeMessage.topics.contains([name]),
|
||||||
|
]
|
||||||
|
if since_id is not None:
|
||||||
|
conditions.append(ScribeMessage.id > since_id)
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(ScribeMessage)
|
||||||
|
.where(*conditions)
|
||||||
|
.order_by(ScribeMessage.id.asc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# mark / tag / untag
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def patch_mailbox_entry(
|
||||||
|
self, session: AsyncSession, entry_id: int, data: MailboxPatchRequest, caller: Caller
|
||||||
|
) -> Tuple[ScribeMailboxEntry, ScribeMessage]:
|
||||||
|
"""`mark` (`read`) and `tag`/`untag` (`add_tags`/`remove_tags`) share
|
||||||
|
this route. An entry outside the caller's tenant, or addressed to
|
||||||
|
an identity the caller is not reading as, answers 404 not_found —
|
||||||
|
indistinguishable from a nonexistent entry, so an id never confirms
|
||||||
|
someone else's mail exists."""
|
||||||
|
tenant = await self._require_tenant(session, caller)
|
||||||
|
|
||||||
|
entry = await session.get(ScribeMailboxEntry, entry_id)
|
||||||
|
if entry is None or entry.owner_tenant != tenant or entry.address != caller.identity:
|
||||||
|
raise ScribeError("not_found", 404)
|
||||||
|
|
||||||
|
if data.read is True and entry.read_at is None:
|
||||||
|
entry.read_at = datetime.now(timezone.utc)
|
||||||
|
elif data.read is False:
|
||||||
|
entry.read_at = None
|
||||||
|
|
||||||
|
if data.add_tags:
|
||||||
|
current = set(entry.tags)
|
||||||
|
entry.tags = list(entry.tags) + [t for t in data.add_tags if t not in current]
|
||||||
|
if data.remove_tags:
|
||||||
|
remove = set(data.remove_tags)
|
||||||
|
entry.tags = [t for t in entry.tags if t not in remove]
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(entry)
|
||||||
|
|
||||||
|
message = await session.get(ScribeMessage, entry.message_id)
|
||||||
|
return entry, message
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
_scribe_service: Optional[ScribeService] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_scribe_service() -> ScribeService:
|
||||||
|
"""Get singleton Scribe service instance"""
|
||||||
|
global _scribe_service
|
||||||
|
if _scribe_service is None:
|
||||||
|
_scribe_service = ScribeService()
|
||||||
|
return _scribe_service
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""
|
||||||
|
Static Domain
|
||||||
|
|
||||||
|
Serves static files for widgets and other frontend assets.
|
||||||
|
"""
|
||||||
|
from src.domains.static.controller import static_controller
|
||||||
|
|
||||||
|
__all__ = ["static_controller"]
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""
|
||||||
|
Static Files Controller
|
||||||
|
|
||||||
|
Serves static files for widgets and other frontend assets.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import FileResponse, HTMLResponse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.shared.base import BaseController
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class StaticController(BaseController):
|
||||||
|
"""
|
||||||
|
Controller for serving static files
|
||||||
|
|
||||||
|
Provides endpoints for:
|
||||||
|
- Organizr widgets
|
||||||
|
- Other static assets
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(prefix="/static", tags=["Static"])
|
||||||
|
# Static files are at the root of the project
|
||||||
|
self.static_dir = Path(__file__).parent.parent.parent.parent / "static"
|
||||||
|
|
||||||
|
def create_router(self) -> APIRouter:
|
||||||
|
"""Create and configure the router"""
|
||||||
|
router = APIRouter(prefix=self.prefix, tags=self.tags)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/widgets/{filename}",
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
summary="Get widget file"
|
||||||
|
)
|
||||||
|
async def get_widget(filename: str):
|
||||||
|
"""
|
||||||
|
Serve widget HTML files
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Widget filename (e.g., service-control.html)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTML file content
|
||||||
|
"""
|
||||||
|
widget_path = self.static_dir / "widgets" / filename
|
||||||
|
|
||||||
|
if not widget_path.exists():
|
||||||
|
return HTMLResponse(
|
||||||
|
content=f"<h1>404 - Widget not found</h1><p>{filename}</p>",
|
||||||
|
status_code=404
|
||||||
|
)
|
||||||
|
|
||||||
|
if not widget_path.is_file():
|
||||||
|
return HTMLResponse(
|
||||||
|
content=f"<h1>400 - Not a file</h1>",
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
# Security: Ensure the path is within the static directory
|
||||||
|
try:
|
||||||
|
widget_path.resolve().relative_to(self.static_dir.resolve())
|
||||||
|
except ValueError:
|
||||||
|
return HTMLResponse(
|
||||||
|
content=f"<h1>403 - Forbidden</h1>",
|
||||||
|
status_code=403
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Serving widget: {filename}")
|
||||||
|
return FileResponse(
|
||||||
|
widget_path,
|
||||||
|
media_type="text/html",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"Expires": "0"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/widgets",
|
||||||
|
summary="List available widgets"
|
||||||
|
)
|
||||||
|
async def list_widgets():
|
||||||
|
"""
|
||||||
|
List all available widget files
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of widget filenames
|
||||||
|
"""
|
||||||
|
widgets_dir = self.static_dir / "widgets"
|
||||||
|
|
||||||
|
if not widgets_dir.exists():
|
||||||
|
return {"widgets": [], "message": "Widgets directory not found"}
|
||||||
|
|
||||||
|
widgets = []
|
||||||
|
for file in widgets_dir.glob("*.html"):
|
||||||
|
widgets.append({
|
||||||
|
"name": file.name,
|
||||||
|
"url": f"/static/widgets/{file.name}",
|
||||||
|
"size": file.stat().st_size
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"widgets": widgets,
|
||||||
|
"count": len(widgets)
|
||||||
|
}
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
# Create controller instance
|
||||||
|
static_controller = StaticController()
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""
|
||||||
|
Tenants Domain
|
||||||
|
|
||||||
|
The tenant registry (core-api T-8 / workspace D-73): tenants, consumer
|
||||||
|
grants, and the one enforcement point every tenant-scoped domain's own
|
||||||
|
`_require_tenant` calls through to (Book, Scribe). Served under /tenants/*.
|
||||||
|
"""
|
||||||
|
from src.domains.tenants.controller import tenants_controller
|
||||||
|
|
||||||
|
__all__ = ["tenants_controller"]
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""
|
||||||
|
Tenants Controller
|
||||||
|
|
||||||
|
The tenant registry's read API (core-api T-8 / workspace D-73): `GET
|
||||||
|
/tenants/me` and `GET /tenants`. No write routes this round — provisioning
|
||||||
|
is later (workspace T-195).
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.domains.auth.callers import Caller, require_caller
|
||||||
|
from src.domains.tenants.schemas import TenantListResponse, TenantMeResponse, TenantSummary
|
||||||
|
from src.domains.tenants.service import TenantError, get_tenants_service
|
||||||
|
from src.shared.base import BaseController
|
||||||
|
from src.shared.database import get_async_session
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
require_tenants_reader = require_caller("office", "user")
|
||||||
|
require_tenants_lister = require_caller("office", "user", "tatlock", "scheduler")
|
||||||
|
|
||||||
|
|
||||||
|
def _summary(tenant) -> TenantSummary:
|
||||||
|
return TenantSummary(
|
||||||
|
id=tenant.id, display_name=tenant.display_name, kind=tenant.kind, status=tenant.status
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantsController(BaseController):
|
||||||
|
"""Controller for the tenant registry's read API."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(prefix="/tenants", tags=["Tenants"])
|
||||||
|
|
||||||
|
def create_router(self) -> APIRouter:
|
||||||
|
router = APIRouter(prefix=self.prefix, tags=self.tags)
|
||||||
|
service = get_tenants_service()
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# me
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/me",
|
||||||
|
response_model=TenantMeResponse,
|
||||||
|
summary="The caller's own tenant and the consumers granted in it",
|
||||||
|
)
|
||||||
|
async def get_me(
|
||||||
|
caller: Caller = Depends(require_tenants_reader),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
tenant, consumers = await service.get_me(session, caller)
|
||||||
|
except TenantError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=error.status_code, detail={"code": error.code}
|
||||||
|
) from error
|
||||||
|
return TenantMeResponse(
|
||||||
|
id=tenant.id,
|
||||||
|
display_name=tenant.display_name,
|
||||||
|
kind=tenant.kind,
|
||||||
|
status=tenant.status,
|
||||||
|
consumers=consumers,
|
||||||
|
)
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# list
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=TenantListResponse,
|
||||||
|
summary="Every tenant (tatlock/scheduler) or the caller's own, as a list of one",
|
||||||
|
)
|
||||||
|
async def list_tenants(
|
||||||
|
caller: Caller = Depends(require_tenants_lister),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
tenants = await service.list_tenants(session, caller)
|
||||||
|
except TenantError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=error.status_code, detail={"code": error.code}
|
||||||
|
) from error
|
||||||
|
return TenantListResponse(tenants=[_summary(tenant) for tenant in tenants])
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
# Create controller instance
|
||||||
|
tenants_controller = TenantsController()
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""
|
||||||
|
Tenants Domain Models
|
||||||
|
|
||||||
|
The tenant registry (core-api T-8 / workspace D-73): every Book and Scribe
|
||||||
|
`owner_tenant` column carries a foreign key to `tenants.id` (migration 007),
|
||||||
|
and `TenantsService.require_tenant` is the one place a caller's tenant is
|
||||||
|
checked known, active, and — for an Office caller — granted to the calling
|
||||||
|
consumer. SQLAlchemy 2.0 Mapped/mapped_column style, timestamptz, following
|
||||||
|
src/domains/book/models.py.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, String, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from src.shared.database import Base
|
||||||
|
|
||||||
|
TENANT_KINDS = ("household", "person", "test")
|
||||||
|
TENANT_STATUSES = ("active", "suspended")
|
||||||
|
|
||||||
|
# The slug shape a tenant id carries, identical to
|
||||||
|
# src/domains/auth/callers.py's `_TENANT_RE` (tatlock's own tenant ids carry
|
||||||
|
# underscores, e.g. `llm_tester`) — not imported from there because that
|
||||||
|
# module's constants are private to it, following
|
||||||
|
# src/domains/scribe/models.py's same note about ADDRESS_NAME_PATTERN.
|
||||||
|
# Asserted equal in tests/test_tenants.py.
|
||||||
|
TENANT_ID_PATTERN = r"^[a-z0-9_][a-z0-9_.-]{0,62}$"
|
||||||
|
|
||||||
|
|
||||||
|
class Tenant(Base):
|
||||||
|
"""A tenant: tatlock's own per-household/person/test namespace."""
|
||||||
|
|
||||||
|
__tablename__ = "tenants"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint("kind IN ('household', 'person', 'test')", name="ck_tenants_kind"),
|
||||||
|
CheckConstraint("status IN ('active', 'suspended')", name="ck_tenants_status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(63), primary_key=True)
|
||||||
|
display_name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
kind: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Tenant {self.id} kind={self.kind} status={self.status}>"
|
||||||
|
|
||||||
|
|
||||||
|
class TenantConsumer(Base):
|
||||||
|
"""A grant: consumer `consumer` may act in tenant `tenant_id`."""
|
||||||
|
|
||||||
|
__tablename__ = "tenant_consumers"
|
||||||
|
|
||||||
|
tenant_id: Mapped[str] = mapped_column(
|
||||||
|
String(63), ForeignKey("tenants.id"), primary_key=True
|
||||||
|
)
|
||||||
|
consumer: Mapped[str] = mapped_column(String(63), primary_key=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<TenantConsumer {self.tenant_id}/{self.consumer}>"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""
|
||||||
|
Tenants Domain Schemas
|
||||||
|
|
||||||
|
Pydantic request/response payloads for the tenant registry (core-api T-8 /
|
||||||
|
workspace D-73). No write routes this round — provisioning is later
|
||||||
|
(workspace T-195).
|
||||||
|
"""
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from src.shared.base import BaseSchema
|
||||||
|
|
||||||
|
|
||||||
|
class TenantSummary(BaseSchema):
|
||||||
|
"""The shape `GET /tenants` answers with, per tenant."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
display_name: str
|
||||||
|
kind: str
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class TenantMeResponse(TenantSummary):
|
||||||
|
"""`GET /tenants/me` — the caller's own tenant plus the consumers
|
||||||
|
granted in it, the cheap "am I allowed here" probe (D-73)."""
|
||||||
|
|
||||||
|
consumers: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
class TenantListResponse(BaseSchema):
|
||||||
|
tenants: List[TenantSummary]
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""
|
||||||
|
Tenants Domain Service
|
||||||
|
|
||||||
|
The tenant registry (core-api T-8 / workspace D-73): `require_tenant` is
|
||||||
|
the one enforcement point Book's and Scribe's own `_require_tenant` now
|
||||||
|
delegate to, rather than each checking only presence (core-api T-1/T-5's
|
||||||
|
original shape, which let a typo'd claim open a fresh, empty namespace
|
||||||
|
instead of being refused).
|
||||||
|
|
||||||
|
It takes the session, so it is one query: a LEFT JOIN against the grant
|
||||||
|
table, so presence, status and (for an Office caller) the grant are all
|
||||||
|
read in a single round trip. When `caller.consumer` is `None` (tatlock,
|
||||||
|
scheduler, or a user-kind caller — core-api T-6 carries no consumer for
|
||||||
|
those), the join condition `TenantConsumer.consumer == None` compiles to
|
||||||
|
`IS NULL`, which never matches a grant row (consumer is NOT NULL there),
|
||||||
|
so the grant side of the row is always empty for them — correct, since the
|
||||||
|
grant check below is skipped outright when `caller.consumer` is `None`.
|
||||||
|
"""
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
from sqlalchemy import and_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.domains.auth.callers import Caller
|
||||||
|
from src.domains.tenants.models import Tenant, TenantConsumer
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantError(Exception):
|
||||||
|
"""Raised for a tenant-registry failure; the controller maps it to a response."""
|
||||||
|
|
||||||
|
def __init__(self, code: str, status_code: int = 400):
|
||||||
|
self.code = code
|
||||||
|
self.status_code = status_code
|
||||||
|
super().__init__(code)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantsService:
|
||||||
|
"""The tenant registry's one enforcement point, and its read API."""
|
||||||
|
|
||||||
|
async def require_tenant(self, session: AsyncSession, caller: Caller) -> str:
|
||||||
|
"""
|
||||||
|
The one enforcement point (core-api T-8 / workspace D-73):
|
||||||
|
|
||||||
|
- no tenant on the caller -> 400 `tenant_required` (unchanged)
|
||||||
|
- tenant not in the table -> 403 `tenant_unknown`
|
||||||
|
- tenant `suspended` -> 403 `tenant_suspended`
|
||||||
|
- an Office caller (carries a consumer) whose consumer holds no
|
||||||
|
grant in that tenant -> 403 `consumer_not_granted`
|
||||||
|
- a user-kind caller carries no consumer (core-api T-6, not this
|
||||||
|
round): tenant checks only.
|
||||||
|
|
||||||
|
Returns the tenant id on success.
|
||||||
|
"""
|
||||||
|
if not caller.tenant:
|
||||||
|
raise TenantError("tenant_required", 400)
|
||||||
|
tenant = caller.tenant
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(Tenant.status, TenantConsumer.consumer)
|
||||||
|
.outerjoin(
|
||||||
|
TenantConsumer,
|
||||||
|
and_(
|
||||||
|
TenantConsumer.tenant_id == Tenant.id,
|
||||||
|
TenantConsumer.consumer == caller.consumer,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(Tenant.id == tenant)
|
||||||
|
)
|
||||||
|
row = (await session.execute(stmt)).first()
|
||||||
|
if row is None:
|
||||||
|
raise TenantError("tenant_unknown", 403)
|
||||||
|
|
||||||
|
status, granted_consumer = row
|
||||||
|
if status == "suspended":
|
||||||
|
raise TenantError("tenant_suspended", 403)
|
||||||
|
if caller.consumer is not None and granted_consumer is None:
|
||||||
|
raise TenantError("consumer_not_granted", 403)
|
||||||
|
|
||||||
|
return tenant
|
||||||
|
|
||||||
|
async def get_me(self, session: AsyncSession, caller: Caller) -> Tuple[Tenant, List[str]]:
|
||||||
|
"""`GET /tenants/me` — through the same helper, so "am I allowed
|
||||||
|
here" shares its answer with every other tenant-scoped call."""
|
||||||
|
tenant_id = await self.require_tenant(session, caller)
|
||||||
|
|
||||||
|
tenant = await session.get(Tenant, tenant_id)
|
||||||
|
consumers_stmt = (
|
||||||
|
select(TenantConsumer.consumer)
|
||||||
|
.where(TenantConsumer.tenant_id == tenant_id)
|
||||||
|
.order_by(TenantConsumer.consumer)
|
||||||
|
)
|
||||||
|
consumers = list((await session.execute(consumers_stmt)).scalars().all())
|
||||||
|
return tenant, consumers
|
||||||
|
|
||||||
|
async def list_tenants(self, session: AsyncSession, caller: Caller) -> List[Tenant]:
|
||||||
|
"""
|
||||||
|
`GET /tenants` — `tatlock` and `scheduler` callers (no tenant) see
|
||||||
|
every tenant; a tenant-bearing caller sees only its own, as a list
|
||||||
|
of one (the hard boundary: another tenant's existence is never
|
||||||
|
revealed, so this still runs through `require_tenant`); a user
|
||||||
|
caller with no tenant is 400 `tenant_required`.
|
||||||
|
"""
|
||||||
|
if caller.tenant is None:
|
||||||
|
if caller.kind in ("tatlock", "scheduler"):
|
||||||
|
stmt = select(Tenant).order_by(Tenant.id)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
raise TenantError("tenant_required", 400)
|
||||||
|
|
||||||
|
tenant_id = await self.require_tenant(session, caller)
|
||||||
|
tenant = await session.get(Tenant, tenant_id)
|
||||||
|
return [tenant]
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
_tenants_service: Optional[TenantsService] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_tenants_service() -> TenantsService:
|
||||||
|
"""Get singleton Tenants service instance"""
|
||||||
|
global _tenants_service
|
||||||
|
if _tenants_service is None:
|
||||||
|
_tenants_service = TenantsService()
|
||||||
|
return _tenants_service
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
Tools Domain
|
||||||
|
|
||||||
|
Provides utility tool endpoints including DNS lookups and system stats.
|
||||||
|
"""
|
||||||
|
from src.domains.tools.controller import tools_controller
|
||||||
|
from src.domains.tools.dns import DNSService, DNSQueryError
|
||||||
|
from src.domains.tools.system import SystemStatsService, SystemStatsResponse
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"tools_controller",
|
||||||
|
"DNSService",
|
||||||
|
"DNSQueryError",
|
||||||
|
"SystemStatsService",
|
||||||
|
"SystemStatsResponse",
|
||||||
|
]
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
"""
|
||||||
|
Tools Controller
|
||||||
|
|
||||||
|
Provides utility tool endpoints including:
|
||||||
|
- DNS lookups
|
||||||
|
- System stats
|
||||||
|
- Environment data (weather, forecast, sun times)
|
||||||
|
"""
|
||||||
|
from typing import Dict, Optional
|
||||||
|
from fastapi import APIRouter, HTTPException, status, Depends
|
||||||
|
|
||||||
|
from src.shared.base import BaseController
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
from src.domains.tools.dns.schemas import DNSLookupRequest, DNSLookupResponse
|
||||||
|
from src.domains.tools.dns.service import DNSService
|
||||||
|
from src.domains.tools.dns.exceptions import DNSQueryError
|
||||||
|
from src.domains.tools.system.schemas import SystemStatsResponse
|
||||||
|
from src.domains.tools.system.service import SystemStatsService
|
||||||
|
from src.domains.tools.environment.schemas import EnvironmentResponse
|
||||||
|
from src.domains.tools.environment.service import EnvironmentService
|
||||||
|
from src.domains.tools.news.schemas import NewsResponse
|
||||||
|
from src.domains.tools.news.service import NewsService
|
||||||
|
from src.domains.auth.oidc import get_optional_user
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ToolsController(BaseController):
|
||||||
|
"""
|
||||||
|
Controller for utility tools
|
||||||
|
|
||||||
|
Provides endpoints for:
|
||||||
|
- DNS lookups
|
||||||
|
- System stats
|
||||||
|
- Environment data (weather, forecast, sun times)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(prefix="/tools", tags=["Tools"])
|
||||||
|
self.dns_service = DNSService()
|
||||||
|
self.system_stats_service = SystemStatsService()
|
||||||
|
self.environment_service = EnvironmentService()
|
||||||
|
self.news_service = NewsService()
|
||||||
|
|
||||||
|
def create_router(self) -> APIRouter:
|
||||||
|
"""Create and configure the router"""
|
||||||
|
router = APIRouter(prefix=self.prefix, tags=self.tags)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/dns/lookup",
|
||||||
|
response_model=DNSLookupResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
summary="Perform DNS lookup",
|
||||||
|
description="""
|
||||||
|
Perform DNS lookups for various record types.
|
||||||
|
|
||||||
|
Uses dnspython for reliable DNS queries with support for multiple record types
|
||||||
|
and custom nameservers. Perfect for troubleshooting DNS issues and checking
|
||||||
|
domain configurations.
|
||||||
|
|
||||||
|
**Supported Record Types:**
|
||||||
|
- A: IPv4 address records
|
||||||
|
- AAAA: IPv6 address records
|
||||||
|
- MX: Mail exchange records
|
||||||
|
- TXT: Text records (SPF, DKIM, etc.)
|
||||||
|
- CNAME: Canonical name records
|
||||||
|
- NS: Nameserver records
|
||||||
|
- SOA: Start of authority records
|
||||||
|
- PTR: Pointer records (reverse DNS)
|
||||||
|
- CAA: Certification authority authorization
|
||||||
|
- SRV: Service records
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Custom nameserver support (e.g., 8.8.8.8, 1.1.1.1)
|
||||||
|
- Query time measurement
|
||||||
|
- Detailed error messages
|
||||||
|
|
||||||
|
**Rate Limiting:** None (internal network use only)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
async def dns_lookup(request: DNSLookupRequest) -> DNSLookupResponse:
|
||||||
|
"""
|
||||||
|
Perform DNS lookup for a domain
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: DNS lookup request with domain, record type, and optional nameserver
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DNS lookup results with records and metadata
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 400 for invalid queries, 500 for processing errors
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info(f"Received DNS lookup request for: {request.domain} ({request.record_type})")
|
||||||
|
result = await self.dns_service.lookup(request)
|
||||||
|
return result
|
||||||
|
|
||||||
|
except DNSQueryError as e:
|
||||||
|
logger.warning(f"DNS query error: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"DNS query failed: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error during DNS lookup: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="An unexpected error occurred during DNS lookup"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/system/stats",
|
||||||
|
response_model=SystemStatsResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
summary="Get host system statistics",
|
||||||
|
description="""
|
||||||
|
Get real-time host system resource statistics.
|
||||||
|
|
||||||
|
Returns CPU, memory, disk, network, and GPU/VRAM usage for the host machine
|
||||||
|
(not Docker container metrics).
|
||||||
|
|
||||||
|
**Metrics Returned:**
|
||||||
|
- **CPU:** Usage percentage, core count, load averages
|
||||||
|
- **Memory:** Usage percentage, total/used/available bytes
|
||||||
|
- **Disk:** Usage percentage, total/used/free bytes (root partition)
|
||||||
|
- **Network:** Total bytes sent/received
|
||||||
|
- **GPU:** VRAM usage (if NVIDIA GPU available via nvidia-smi)
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- Dashboard system monitoring widgets
|
||||||
|
- Health checks and alerting
|
||||||
|
- Capacity planning
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
async def get_system_stats() -> SystemStatsResponse:
|
||||||
|
"""
|
||||||
|
Get current host system statistics
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
System statistics including CPU, memory, disk, network, and GPU
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 500 for processing errors
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info("Fetching system stats")
|
||||||
|
result = await self.system_stats_service.get_stats()
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get system stats: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to collect system stats: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/environment",
|
||||||
|
response_model=EnvironmentResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
summary="Get environment data",
|
||||||
|
description="""
|
||||||
|
Get current environment data including weather, forecast, and sun times.
|
||||||
|
|
||||||
|
Fetches data from the Qdrant volatile collection for the authenticated user.
|
||||||
|
Falls back to 'default' user if not authenticated.
|
||||||
|
|
||||||
|
**Data Returned:**
|
||||||
|
- **Weather:** Current temperature, conditions, humidity, wind
|
||||||
|
- **Forecast:** Multi-day weather outlook
|
||||||
|
- **Sun Times:** Sunrise, sunset, daylight duration
|
||||||
|
- **Air Quality:** AQI and pollutant levels (if available)
|
||||||
|
|
||||||
|
**Data Source:** Qdrant volatile_{user} collection
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- Dashboard environment widgets
|
||||||
|
- Home automation context
|
||||||
|
- Weather-based automations
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
async def get_environment(
|
||||||
|
user: Optional[Dict] = Depends(get_optional_user),
|
||||||
|
) -> EnvironmentResponse:
|
||||||
|
"""
|
||||||
|
Get current environment data
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: Optional authenticated user from OIDC
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Environment data including weather, forecast, sun times
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 500 for processing errors
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get user identifier from OIDC claims, fallback to 'default'
|
||||||
|
user_id = "default"
|
||||||
|
if user:
|
||||||
|
user_id = user.get("preferred_username") or user.get("sub", "default")
|
||||||
|
# Strip email domain if present (e.g., "user@example.com" -> "user")
|
||||||
|
if "@" in user_id:
|
||||||
|
user_id = user_id.split("@")[0]
|
||||||
|
|
||||||
|
logger.info(f"Fetching environment data for user: {user_id}")
|
||||||
|
result = await self.environment_service.get_current(user_id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get environment data: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to fetch environment data: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/news",
|
||||||
|
response_model=NewsResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
summary="Get news headlines",
|
||||||
|
description="""
|
||||||
|
Get news headlines for the authenticated user.
|
||||||
|
|
||||||
|
Fetches news data from the Qdrant volatile collection for the authenticated user.
|
||||||
|
Falls back to 'default' user if not authenticated.
|
||||||
|
|
||||||
|
**Data Returned:**
|
||||||
|
- **Headlines:** List of news headlines with title, description, source, url
|
||||||
|
- **Category:** News category (general, technology, etc.)
|
||||||
|
- **Sources:** List of news sources
|
||||||
|
|
||||||
|
**Data Source:** Qdrant volatile_{user} collection (news namespace)
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- Dashboard news ticker
|
||||||
|
- News feed widgets
|
||||||
|
- Information display
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
async def get_news(
|
||||||
|
user: Optional[Dict] = Depends(get_optional_user),
|
||||||
|
) -> NewsResponse:
|
||||||
|
"""
|
||||||
|
Get news headlines
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: Optional authenticated user from OIDC
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
News headlines response
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 500 for processing errors
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get user identifier from OIDC claims, fallback to 'default'
|
||||||
|
user_id = "default"
|
||||||
|
if user:
|
||||||
|
user_id = user.get("preferred_username") or user.get("sub", "default")
|
||||||
|
# Strip email domain if present (e.g., "user@example.com" -> "user")
|
||||||
|
if "@" in user_id:
|
||||||
|
user_id = user_id.split("@")[0]
|
||||||
|
|
||||||
|
logger.info(f"Fetching news data for user: {user_id}")
|
||||||
|
result = await self.news_service.get_news(user_id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get news data: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to fetch news data: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
# Create controller instance
|
||||||
|
tools_controller = ToolsController()
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
DNS Tools Module
|
||||||
|
|
||||||
|
Provides DNS lookup functionality.
|
||||||
|
"""
|
||||||
|
from src.domains.tools.dns.schemas import DNSLookupRequest, DNSLookupResponse, DNSRecord
|
||||||
|
from src.domains.tools.dns.service import DNSService
|
||||||
|
from src.domains.tools.dns.exceptions import DNSQueryError
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DNSLookupRequest",
|
||||||
|
"DNSLookupResponse",
|
||||||
|
"DNSRecord",
|
||||||
|
"DNSService",
|
||||||
|
"DNSQueryError",
|
||||||
|
]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""
|
||||||
|
DNS Exceptions
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class DNSQueryError(Exception):
|
||||||
|
"""Raised when a DNS query fails"""
|
||||||
|
pass
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""
|
||||||
|
Pydantic schemas for DNS lookup module
|
||||||
|
"""
|
||||||
|
from pydantic import Field
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
from src.shared.base import BaseSchema
|
||||||
|
|
||||||
|
|
||||||
|
class DNSLookupRequest(BaseSchema):
|
||||||
|
"""Request model for DNS lookup"""
|
||||||
|
|
||||||
|
domain: str = Field(
|
||||||
|
...,
|
||||||
|
description="The domain name to lookup",
|
||||||
|
examples=["example.com", "google.com"],
|
||||||
|
min_length=1,
|
||||||
|
max_length=255
|
||||||
|
)
|
||||||
|
|
||||||
|
record_type: str = Field(
|
||||||
|
default="A",
|
||||||
|
description="DNS record type to query (A, AAAA, MX, TXT, CNAME, NS, SOA, PTR, CAA)",
|
||||||
|
examples=["A", "AAAA", "MX", "TXT", "CNAME"]
|
||||||
|
)
|
||||||
|
|
||||||
|
nameserver: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Optional nameserver to use for the query (e.g., 8.8.8.8, 1.1.1.1)",
|
||||||
|
examples=["8.8.8.8", "1.1.1.1", "9.9.9.9"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DNSRecord(BaseSchema):
|
||||||
|
"""Single DNS record result"""
|
||||||
|
|
||||||
|
value: str = Field(
|
||||||
|
...,
|
||||||
|
description="The DNS record value"
|
||||||
|
)
|
||||||
|
|
||||||
|
ttl: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Time to live in seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
priority: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Priority (for MX records)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DNSLookupResponse(BaseSchema):
|
||||||
|
"""Response model for DNS lookup"""
|
||||||
|
|
||||||
|
domain: str = Field(
|
||||||
|
...,
|
||||||
|
description="The queried domain name"
|
||||||
|
)
|
||||||
|
|
||||||
|
record_type: str = Field(
|
||||||
|
...,
|
||||||
|
description="DNS record type queried"
|
||||||
|
)
|
||||||
|
|
||||||
|
records: List[DNSRecord] = Field(
|
||||||
|
...,
|
||||||
|
description="List of DNS records found"
|
||||||
|
)
|
||||||
|
|
||||||
|
nameserver_used: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Nameserver used for the query"
|
||||||
|
)
|
||||||
|
|
||||||
|
query_time_ms: float = Field(
|
||||||
|
...,
|
||||||
|
description="Query execution time in milliseconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
queried_at: datetime = Field(
|
||||||
|
...,
|
||||||
|
description="UTC timestamp when query was executed"
|
||||||
|
)
|
||||||
|
|
||||||
|
success: bool = Field(
|
||||||
|
...,
|
||||||
|
description="Whether the query was successful"
|
||||||
|
)
|
||||||
|
|
||||||
|
error_message: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Error message if query failed"
|
||||||
|
)
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""
|
||||||
|
DNS Lookup Service
|
||||||
|
|
||||||
|
Provides DNS query functionality using dnspython library.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import dns.resolver
|
||||||
|
import dns.exception
|
||||||
|
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
from src.domains.tools.dns.schemas import DNSLookupRequest, DNSLookupResponse, DNSRecord
|
||||||
|
from src.domains.tools.dns.exceptions import DNSQueryError
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DNSService:
|
||||||
|
"""
|
||||||
|
Service for performing DNS lookups
|
||||||
|
|
||||||
|
Uses dnspython for reliable DNS queries with support for
|
||||||
|
various record types and custom nameservers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SUPPORTED_RECORD_TYPES = [
|
||||||
|
"A", "AAAA", "MX", "TXT", "CNAME", "NS", "SOA", "PTR", "CAA", "SRV"
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize DNS service"""
|
||||||
|
self.resolver = dns.resolver.Resolver()
|
||||||
|
self.resolver.timeout = 5.0
|
||||||
|
self.resolver.lifetime = 10.0
|
||||||
|
|
||||||
|
async def lookup(self, request: DNSLookupRequest) -> DNSLookupResponse:
|
||||||
|
"""
|
||||||
|
Perform DNS lookup for the specified domain and record type
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: DNS lookup request with domain, record type, and optional nameserver
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DNSLookupResponse with query results
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
DNSQueryError: If the DNS query fails
|
||||||
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
record_type = request.record_type.upper()
|
||||||
|
|
||||||
|
if record_type not in self.SUPPORTED_RECORD_TYPES:
|
||||||
|
raise DNSQueryError(
|
||||||
|
f"Unsupported record type: {record_type}. "
|
||||||
|
f"Supported types: {', '.join(self.SUPPORTED_RECORD_TYPES)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
resolver = dns.resolver.Resolver()
|
||||||
|
resolver.timeout = 5.0
|
||||||
|
resolver.lifetime = 10.0
|
||||||
|
|
||||||
|
nameserver_used = None
|
||||||
|
if request.nameserver:
|
||||||
|
resolver.nameservers = [request.nameserver]
|
||||||
|
nameserver_used = request.nameserver
|
||||||
|
logger.info(f"Using custom nameserver: {request.nameserver}")
|
||||||
|
else:
|
||||||
|
nameserver_used = resolver.nameservers[0] if resolver.nameservers else "system"
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Performing DNS lookup: {request.domain} ({record_type})")
|
||||||
|
|
||||||
|
answers = resolver.resolve(request.domain, record_type)
|
||||||
|
|
||||||
|
records = []
|
||||||
|
for rdata in answers:
|
||||||
|
record = self._parse_record(rdata, record_type)
|
||||||
|
if record:
|
||||||
|
records.append(record)
|
||||||
|
|
||||||
|
query_time_ms = (time.time() - start_time) * 1000
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"DNS lookup successful: {request.domain} ({record_type}) - "
|
||||||
|
f"Found {len(records)} records in {query_time_ms:.2f}ms"
|
||||||
|
)
|
||||||
|
|
||||||
|
return DNSLookupResponse(
|
||||||
|
domain=request.domain,
|
||||||
|
record_type=record_type,
|
||||||
|
records=records,
|
||||||
|
nameserver_used=nameserver_used,
|
||||||
|
query_time_ms=round(query_time_ms, 2),
|
||||||
|
queried_at=datetime.now(timezone.utc),
|
||||||
|
success=True,
|
||||||
|
error_message=None
|
||||||
|
)
|
||||||
|
|
||||||
|
except dns.resolver.NXDOMAIN:
|
||||||
|
error_msg = f"Domain not found: {request.domain}"
|
||||||
|
logger.warning(error_msg)
|
||||||
|
return self._error_response(request, nameserver_used, start_time, error_msg)
|
||||||
|
|
||||||
|
except dns.resolver.NoAnswer:
|
||||||
|
error_msg = f"No {record_type} records found for {request.domain}"
|
||||||
|
logger.warning(error_msg)
|
||||||
|
return self._error_response(request, nameserver_used, start_time, error_msg)
|
||||||
|
|
||||||
|
except dns.resolver.Timeout:
|
||||||
|
error_msg = f"DNS query timeout for {request.domain}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return self._error_response(request, nameserver_used, start_time, error_msg)
|
||||||
|
|
||||||
|
except dns.exception.DNSException as e:
|
||||||
|
error_msg = f"DNS error: {str(e)}"
|
||||||
|
logger.error(f"DNS query failed for {request.domain}: {e}")
|
||||||
|
return self._error_response(request, nameserver_used, start_time, error_msg)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Unexpected error: {str(e)}"
|
||||||
|
logger.error(f"Unexpected error during DNS lookup: {e}", exc_info=True)
|
||||||
|
return self._error_response(request, nameserver_used, start_time, error_msg)
|
||||||
|
|
||||||
|
def _parse_record(self, rdata, record_type: str) -> Optional[DNSRecord]:
|
||||||
|
"""Parse DNS record data into DNSRecord schema"""
|
||||||
|
try:
|
||||||
|
if record_type == "A" or record_type == "AAAA":
|
||||||
|
return DNSRecord(value=str(rdata), ttl=None)
|
||||||
|
|
||||||
|
elif record_type == "MX":
|
||||||
|
return DNSRecord(
|
||||||
|
value=str(rdata.exchange),
|
||||||
|
priority=rdata.preference,
|
||||||
|
ttl=None
|
||||||
|
)
|
||||||
|
|
||||||
|
elif record_type == "TXT":
|
||||||
|
txt_value = " ".join([s.decode() if isinstance(s, bytes) else str(s) for s in rdata.strings])
|
||||||
|
return DNSRecord(value=txt_value, ttl=None)
|
||||||
|
|
||||||
|
elif record_type in ["CNAME", "NS", "PTR"]:
|
||||||
|
return DNSRecord(value=str(rdata.target), ttl=None)
|
||||||
|
|
||||||
|
elif record_type == "SOA":
|
||||||
|
soa_value = f"mname={rdata.mname} rname={rdata.rname} serial={rdata.serial}"
|
||||||
|
return DNSRecord(value=soa_value, ttl=None)
|
||||||
|
|
||||||
|
elif record_type == "CAA":
|
||||||
|
caa_value = f"{rdata.flags} {rdata.tag.decode() if isinstance(rdata.tag, bytes) else rdata.tag} {rdata.value.decode() if isinstance(rdata.value, bytes) else rdata.value}"
|
||||||
|
return DNSRecord(value=caa_value, ttl=None)
|
||||||
|
|
||||||
|
elif record_type == "SRV":
|
||||||
|
srv_value = f"{rdata.target} port={rdata.port} priority={rdata.priority} weight={rdata.weight}"
|
||||||
|
return DNSRecord(
|
||||||
|
value=srv_value,
|
||||||
|
priority=rdata.priority,
|
||||||
|
ttl=None
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
return DNSRecord(value=str(rdata), ttl=None)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to parse {record_type} record: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _error_response(
|
||||||
|
self,
|
||||||
|
request: DNSLookupRequest,
|
||||||
|
nameserver_used: Optional[str],
|
||||||
|
start_time: float,
|
||||||
|
error_message: str
|
||||||
|
) -> DNSLookupResponse:
|
||||||
|
"""Create an error response for failed DNS queries"""
|
||||||
|
query_time_ms = (time.time() - start_time) * 1000
|
||||||
|
|
||||||
|
return DNSLookupResponse(
|
||||||
|
domain=request.domain,
|
||||||
|
record_type=request.record_type.upper(),
|
||||||
|
records=[],
|
||||||
|
nameserver_used=nameserver_used,
|
||||||
|
query_time_ms=round(query_time_ms, 2),
|
||||||
|
queried_at=datetime.now(timezone.utc),
|
||||||
|
success=False,
|
||||||
|
error_message=error_message
|
||||||
|
)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""
|
||||||
|
Environment data module for Tools domain.
|
||||||
|
|
||||||
|
Provides access to weather, forecast, sun times, and air quality data
|
||||||
|
from the Qdrant volatile collection.
|
||||||
|
"""
|
||||||
|
from src.domains.tools.environment.schemas import (
|
||||||
|
WeatherData,
|
||||||
|
ForecastDay,
|
||||||
|
SunTimesData,
|
||||||
|
AirQualityData,
|
||||||
|
EnvironmentResponse,
|
||||||
|
)
|
||||||
|
from src.domains.tools.environment.service import EnvironmentService
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"WeatherData",
|
||||||
|
"ForecastDay",
|
||||||
|
"SunTimesData",
|
||||||
|
"AirQualityData",
|
||||||
|
"EnvironmentResponse",
|
||||||
|
"EnvironmentService",
|
||||||
|
]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user