A tenant has been an unchecked string since T-4/T-7: the Office's
X-Office-Tenant header or a user JWT's tatlock_user claim, with Book and
Scribe each running their own _require_tenant that checked only presence.
A typo'd claim opened a fresh, empty namespace rather than being refused.
src/domains/tenants/ (controller/models/schemas/service) adds tenants and
tenant_consumers (migration 007, down_revision="006"): tenants.id is the
slug a tatlock tenant carries, kind (household|person|test) and status
(active|suspended) CHECK-constrained; tenant_consumers is the grant pairing
a consumer to a tenant. Migration 007 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. Every existing owner_tenant column (book_records,
book_tenant_counters, scribe_addresses, scribe_messages,
scribe_mailbox_entries — five, grepped and confirmed) now carries a
foreign key to tenants.id, ON DELETE RESTRICT, at both the migration and
the ORM model.
TenantsService.require_tenant is the one enforcement point: no tenant is
400 tenant_required (unchanged); an unknown tenant is 403 tenant_unknown;
a suspended one is 403 tenant_suspended; an Office caller whose consumer
holds no grant in that tenant is 403 consumer_not_granted. One query — a
LEFT JOIN against the grant — so presence, status and the grant are all
read in a single round trip. Book's and Scribe's own _require_tenant now
delegate to it, translating TenantError to their own exception type so
every call site and each controller's single except clause keep working
unchanged.
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 — another tenant's existence is never revealed.
No write routes; provisioning a tenant is workspace T-195.
Tests: tests/test_tenants.py and tests/test_tenants_migration.py (new);
a TestTenantRegistryEnforcement class added to both tests/test_book.py and
tests/test_scribe.py proving all four cases (unknown, suspended,
ungranted-consumer, and the control) on a live Book and Scribe route, not
only on /tenants itself. tests/conftest.py gains a tenant_registry schema
fixture and a suspended_tenant fixture (inserted and deleted per test; no
FK references it); book_schema and scribe_schema each independently grant
a second, test-environment-only consumer (office-gemini) in both seeded
tenants, since test_book.py and test_scribe.py have used it as a stand-in
second consumer since before this grant check existed — the migration
itself still seeds only office-claude, matching the dossier.
Mutation-checked: dropping the grant check fails the three
consumer_not_granted tests (200 instead of 403); dropping the status
check fails the three tenant_suspended tests; making Book bypass the
helper fails its unknown-tenant test (200 instead of 403). All three
restored and the full suite re-verified green (508 + 35 = 543 passed).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
005 has never run in production (still at 004), so it is amended in place
rather than patched later by a 007: book_records gains owner_tenant (the
tatlock tenant, Caller.tenant from T-4) and number, a short outward id
allocated per tenant via a new book_tenant_counters row locked FOR UPDATE
in the same transaction as the insert — INSERT ... ON CONFLICT DO NOTHING
bootstraps the counter row so a tenant's very first allocation doesn't
race its own creation. Unique on (owner_tenant, number); composite index
on (owner_tenant, kind, status). 006 applies on top unchanged.
Every Book route now requires a tenant (BookService._require_tenant,
mirroring the Scribe's own pattern) and filters on it: a record in
another tenant answers exactly like one that does not exist, in both
directions. link and add's parent convenience both route through
_get_or_404, so a link can never reach another tenant — which is also
what keeps show's links/backlinks free of their own tenant filter, since
every link touching a tenant's record is created tenant-safe already.
Responses include number; list accepts number= to find one.
Verified: core_api_test downgraded to 004 (with the original migration,
to match its already-applied pre-amendment schema), amended, upgraded
back to head — clean round trip. 508 tests pass (489 baseline + 19 new:
3 migration-shape, 16 tenant/numbering). Mutation checks confirmed and
reverted: dropping list's tenant filter, allocating numbers from a global
counter, and dropping link's tenant check on its target each fail the
test built to catch it. Raw REST against a dev server on :8777 against
core_api_test confirms the same behaviour over the wire: two tenants'
first records both land on number 1, and a cross-tenant read answers 404
not_found.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Office (workspace D-66, D-68, D-73) needs a message bus: an address is
a topic named for an identity, owned by a tenant and a consumer. D-68 puts
it here, in core-api's own database, behind its own auth, following
src/domains/book/.
Migration 006 lands the whole schema by hand (down_revision="005"):
scribe_addresses (owner_tenant + name primary key — no surrogate id, since
no route ever references an address except by name), scribe_messages
(retained; a generated tsvector search column + GIN index on subject/body,
and a GIN index on the topics array for history's lookup), and
scribe_mailbox_entries (one row per recipient address per message, unique
on address+message_id). No data: seeding `jeroen` and `tower-control` is a
deploy step through `register`, not this migration.
Routes, every one behind require_caller("office", "user"), tenant from the
caller (T-4):
- register/list addresses — register is idempotent for the same consumer,
409 address_taken for another consumer in the tenant; list marks which
addresses the caller's consumer owns.
- send — `from` is always the caller's own identity, stamped server-side,
never read from the body; 403 identity_not_owned if that identity is not
a registered address owned by the caller's consumer; 404 unknown_address
(naming the missing ones) if any recipient does not exist in the tenant;
writes one mailbox entry per recipient.
- mailbox / find — the caller's own identity's entries (mailbox: unread
first; find: full text via websearch_to_tsquery), both requiring the
identity be owned by the caller's consumer.
- history — the retained messages sent to an address, oldest first;
reading never consumes or marks, and reads scribe_messages directly
rather than joining through mailbox entries.
- mark / tag / untag — share one PATCH route; an entry outside the
caller's tenant, or addressed to an identity the caller isn't reading
as, answers 404 not_found — indistinguishable from a nonexistent entry.
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 (test_scribe.py::TestTenantIsolation,
including two tests proving the tenant filter is load-bearing even when
the same address *name* legitimately exists, differently owned, in two
tenants — not just when the caller's own identity is unregistered there).
Messaging only this round: groups, subscriptions, the notification matrix
and delivery are explicitly not built (workspace T-176) — the schema
already carries what `topics`/`tags` need for them.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Office (workspace D-73) now signs every visitor in through Authentik
with one OAuth client per consumer, and every token carries a signed
tatlock_user claim. Caller gains consumer (X-Office-Consumer, the
Authentik application slug) and tenant (X-Office-Tenant, the
tatlock_user claim). For kind "office" both 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 — a header must not be able to
put a non-Office caller into a tenant.
X-Office-Client (informational) is retired in favour of
X-Office-Consumer. book_events.client keeps its name (no migration this
round) and now records the consumer instead.
Book routes do not start filtering by tenant this round — that is
core-api T-2.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Office (workspace D-64..D-72) needs a store for Notes, Tasks,
Decisions, Questions and Rejected options. D-68 puts it here, in
core-api's own database, behind its own auth.
Migration 005 lands the whole schema now, by hand (down_revision="004";
the chain has a hash-named revision between 003 and 004, confirmed
before trusting the numbering): book_records (kind, status validated
per kind in the service, scope work/personal, a generated tsvector
search column + GIN index), book_tags, book_record_links, book_attachments
(table only, no file handling yet), and book_events — append-only via a
database trigger that raises on UPDATE and DELETE for every role,
including the owning application.
Routes this round (D-70's core verbs), every one behind
require_caller("office", "user"), writes with identity_required=True,
every change writing a book_events row: add, show, list (the nine
views), find (full-text), change, close (task done/dropped; question
resolved via a `resolves` link into `settled`; decision superseded via
a `supersedes` link into `superseded`), link/unlink, tag/untag, history.
Append, remind, reschedule, undo, purge, redact, pin, check, snooze,
archive/trash/restore, pause/resume, due, next, batch calls and
idempotency keys are explicitly not this round — the schema already
carries what they need.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
api.schweitz.net is internet-facing with no forward-auth, and every
GET under /housekeeping/* (health, devices, areas, scenes, scripts,
automations, history) was unauthenticated. It answers 500 today only
because Home Assistant's token is dead; once that token is restored it
would serve the house's device list to anyone.
Reads now go through require_caller("office", "tatlock", "user").
tatlock is in the list because tatlock's Housekeeper
(tatlock/src/agents/housekeeper/client.py) is the only caller of these
routes today and already sends Bearer CORE_API_KEY when it is set.
Writes are untouched — control_device, activate_scene, run_script and
toggle_automation still depend on get_admin_user. Whether tatlock may
write is a separate decision, not this one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
core-api had no service-key auth: AuthService.validate_api_key was never
called, api_keys stayed empty, and CORE_API_KEY was read by nothing. This
is the first thing that reads a service key.
require_caller(*allowed, identity_required=False) resolves, in order:
office_service_key -> tatlock_service_key -> scheduler_service_key ->
the existing get_current_user() JWT path. Each static key is compared
with hmac.compare_digest, gated on the configured key being non-empty so
an unset key can never match. A disallowed kind is 403 forbidden.
The office caller's identity comes from X-Office-Identity: required on
writes (400 identity_required if missing), validated against
^[a-z0-9][a-z0-9-]{0,62}$ and against the reserved/group- set (400
identity_invalid). Other kinds carry a fixed identity and ignore the
header.
New settings (src/shared/config.py, default ""): office_service_key,
tatlock_service_key, scheduler_service_key; and book_timezone
("Europe/Amsterdam") for the Book's due/remind handling.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Add NewsHeadline and NewsResponse schemas
- Add NewsService for parsing news from Qdrant volatile collection
- Add GET /tools/news endpoint to tools controller
- News fetched from 'news' namespace in volatile_{user} collection
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Forecast: Check 'daily' key first (scheduler stores count in 'days')
- Sun times: Use ISO fields, calculate daylight from various sources
- Air quality: Support aqi_us/aqi_european and nitrogen_dioxide fields
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
python-jose jwt.decode() requires audience as string or None, not list.
Now extract and validate audience from unverified claims first,
then use token's actual audience for JWT decode.
Fixes "audience must be a string or None" error.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Changed oidc_audience (string) to oidc_audiences (list)
- Now accepts tokens with audience: core-api, tatlock-ui, tatlock
- Fixes environment endpoint returning "default" user when using
tatlock-ui token (audience mismatch was causing JWT claims error)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove src/models/ollama_client.py, embeddings.py, embeddings_ollama.py
- Remove model aliases and AI config from settings (both config.py files)
- Update health endpoints to only check database connectivity
- Update tests to reflect database-only health checks
- Update README, .env.example, and OIDC docstrings
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The domains.auth.oidc module had its own oidc_config instance that
wasn't being configured, causing environment endpoint to always use
hardcoded "local" user instead of authenticated user.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The environment endpoint was importing from non-existent path
`src.oidc.dependencies` instead of `src.auth.oidc`, causing
authentication to fail and queries to go to wrong Qdrant collection.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add GET /tools/environment endpoint that fetches weather, forecast, sun times,
and air quality data from user's volatile Qdrant collection.
- Add qdrant-client dependency
- Create QdrantReadClient wrapper for read-only queries
- Add environment schemas and service in tools domain
- Parse weather, forecast, sun times, and air quality from Qdrant payloads
- Support user-specific collections via preferred_username from OIDC
- Add comprehensive service tests (13 tests)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Initialize user.roles=[] and user.preferences on new user creation
to avoid MissingGreenlet error in async context.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Authentik JWT sub claim may not be a valid UUID.
Now derives a deterministic UUID from the sub string if parsing fails.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Added get_current_user_or_forward_auth() combined dependency that:
- First checks for X-authentik-* headers from NPM forward auth (web)
- Falls back to JWT Bearer token validation (mobile/native)
This fixes web authentication where browsers don't send Bearer tokens
but rely on NPM's forward auth proxy to pass user info via headers.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
When allow_credentials=True, browsers reject wildcard (*) origins.
Added specific allowed origins for Tatlock domains.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add endpoint to get current user profile from NPM forward auth headers.
Enables web authentication flow where NPM handles Authentik login.
- Read X-authentik-* headers set by NPM forward auth
- Auto-create user if not in database (first login via web)
- Sync roles from current Authentik groups
- Add get_user_by_email and get_user_by_authentik_id helpers
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add user profile, preferences, and API key management endpoints:
- 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 use tak_ prefix, SHA-256 hashing, and are shown only once on creation.
Preferences support partial updates with JSON merge behavior.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Architecture changes:
- Permission format: domain.category:action (e.g., control-room.general:admin)
- Decoupled groups from roles via group_roles mapping table
- Groups are organizational (synced from Authentik)
- Roles are permissions (admin-managed via API)
New features:
- require_permission() and require_any_permission() dependency factories
- Action hierarchy: admin > editor > user > viewer
- Global admin override (admin.general:admin grants all)
- Group-role management endpoints (assign/remove roles)
- GET /auth/roles endpoint to list all roles
Database changes:
- Added category column to roles table (default: general)
- Removed authentik_group column (decoupled)
- Added group_roles association table
- Added user_groups association table
- Migration updates role names to domain.general:action format
Tests:
- 67 new tests for auth service and controller
- Covers token validation, user sync, role sync
- Covers group-role assignment/removal
- Covers schema conversions and permission system
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- GET /tools/system/stats - Real-time host system statistics
- CPU usage, memory, all mounted disks, network I/O
- GPU/VRAM stats via nvidia-smi (if available)
- Uses psutil for cross-platform host metrics
- Auto-discovers and filters real filesystems
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Adds link_type column to quick_links table for iframe vs new_tab behavior.
Includes Alembic migration and schema updates.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Dashboard domain with Quick Links CRUD + reorder endpoints
- Dashboard widgets management endpoints
- Database migrations for quick_links and dashboard_widgets tables
- Static file controller for Organizr widgets
- Default local user when OIDC is disabled
- Domain-based architecture refactor (src/domains/, src/shared/)
- Test suite updated for new structure (285 tests passing)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- GET /auth/groups - list groups with search/pagination
- POST /auth/groups/sync-from-authentik - bulk sync from Authentik
- Group model and database migration
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove hardcoded default URLs (portainer, npm, ollama, etc.)
- All external service URLs now required via ENV vars
- Remove obsolete ai_client and ai_controller (core-ai proxy)
- Clean up health_controller obsolete core-ai references
- App fails fast at startup if required config missing
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Simplify root endpoint to return minimal status info
- Remove obsolete endpoint listings (moved to /docs)
- Clean up verbose app description
- Disable redoc UI
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove _list_containers_via_socket method
- Remove _inspect_container_via_socket method
- Simplify list_containers and inspect_container to use Portainer API only
- Raise RuntimeError when no Portainer endpoints available
BREAKING: Portainer API configuration is now required for all container
and stack operations. Set PORTAINER_URL and PORTAINER_API_KEY env vars.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove credentials.py import, use pydantic-settings .env support
- Remove unused search API keys (Brave, Google)
- Update default search provider to SearXNG
- Add extra="ignore" to allow flexible env var usage
- Update .env.example with organized sections
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add 300ms delay after executing device action before fetching new state,
allowing Home Assistant time to update the entity state.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Web scraping functionality is no longer needed. Removes:
- src/web_scraper/ directory (6 files)
- /web-scraper/scrape endpoint from tools controller
- References from health controller endpoints list
Tools controller now only contains DNS lookup functionality.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Introduces home automation endpoints for the Tatlock Housekeeper agent:
- Device discovery and control (turn_on, turn_off, toggle, set_brightness)
- Scene activation and script execution
- Automation management (enable/disable)
- State history queries and area discovery
Includes Home Assistant REST client and configuration.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Include version in /health endpoint response
- Update test to verify version field
- Bump version to 1.1.2
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>