From f84b3a8928b25ff55ab0610e6f0022aeafe5cec7 Mon Sep 17 00:00:00 2001 From: Oleksii Dolhov Date: Thu, 21 May 2026 15:43:44 +0300 Subject: [PATCH 01/17] feat(enterprise): private submodule + EntitlementService seam + SSO PoC (#847 Phase 0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #847 spike. Establishes the open-core split between the public Trinity backend and a private companion repo `Abilityai/trinity-enterprise` that ships compliance-gating features (SSO, SCIM, SIEM) without merging them into the public codebase. The spike research is in `docs/planning/OSS_ENTERPRISE_SPLIT_RESEARCH.md` (521 lines, six load-bearing decisions, stress-tested against eight other open issues). The condensed decision record is `docs/planning/ENTERPRISE_ARCHITECTURE.md`. What lands in this PR (Phase 0): Public-repo seam (the integration mechanism): * `src/backend/services/entitlement_service.py` — `EntitlementService` Phase 0 stub. Returns True for every `is_entitled(feature_id)` check unless `TRINITY_OSS_ONLY=1` env is set, in which case every check flips False. Module-level singleton + `_set_for_testing` test seam. * `src/backend/dependencies.py:requires_entitlement(feature_id)` — FastAPI dependency factory mirroring `require_role`. Raises HTTP 403 naming the missing feature so the UI can surface a "license required" toast. Lazy-imports the service so tests can swap singletons. * `src/backend/main.py` — conditional `try: from enterprise import register_enterprise except ImportError`. Loaders mounted under `/api/enterprise/*` when the submodule is present; OSS-only builds log an informational message and continue. Idempotent via `app.state.enterprise_registered`. * `src/backend/routers/settings.py` — `/api/settings/feature-flags` extended with `enterprise_features: list[str]`. Drives UI tab visibility. * `.gitmodules` + new submodule mount at `src/backend/enterprise` pointing to the private repo via SSH. * `docker-compose.yml` env pass-through for `TRINITY_OSS_ONLY`. * `.github/workflows/build-without-submodule.yml` — boots the backend without the enterprise submodule and asserts `/health` responds, `/api/settings/feature-flags` returns `enterprise_features: []`, `/api/enterprise/sso/providers` returns 404, and the OSS-only log line is emitted. Catches regressions where the conditional import becomes hard-required. Private-repo PoC content (in `Abilityai/trinity-enterprise`, mounted at `src/backend/enterprise/`): * `__init__.py` — `register_enterprise(app)` single entry point. * `sso/router.py` — `/api/enterprise/sso/{providers,login/{id}}` stubs. `GET /providers` returns the in-process registry (empty by default); `POST /login/{id}` returns 501 "PoC stub" or 404 for unknown id. Both gated by `requires_entitlement("sso")` with a lazy-import fallback so the private repo tests in isolation. * `sso/providers.py` — `SSOProvider` ABC + `StubProvider`. Tests (`tests/unit/test_847_entitlement_seam.py`, 14 cases): * EntitlementService default + TRINITY_OSS_ONLY deny path * Parametrised truthy/falsy env spellings * `requires_entitlement` allow/deny (skip-on-no-passlib for local) * `_set_for_testing` singleton swap * Static check that main.py uses conditional ImportError guard Docs: * `docs/planning/ENTERPRISE_ARCHITECTURE.md` — condensed decision * `docs/planning/OSS_ENTERPRISE_SPLIT_RESEARCH.md` — long-form research * `docs/dev/ENTERPRISE_LOCAL_DEV.md` — 15-min clone-to-running guide * `docs/memory/requirements.md` §34.1 Live verification (local instance): * Submodule mounted at `src/backend/enterprise/` * `GET /api/enterprise/sso/providers` → `[]` * `GET /api/settings/feature-flags` → `enterprise_features: ["sso","scim","siem"]` * `POST /api/enterprise/sso/login/foo` → HTTP 404 "Unknown SSO provider 'foo'" * With `TRINITY_OSS_ONLY=1`: `/api/enterprise/sso/providers` returns 403 "Enterprise feature 'sso' is not licensed", `enterprise_features: []` * Restored default: re-entitled Out of scope (separate follow-up issues): * Phase 1: Ed25519-signed license token + verify path + admin License UI * Phase 2: extract `audit_log` into the submodule as first real enterprise module * Phase 3: prove "core-primitive + enterprise-knob" pattern via #834 * Phase 4: real SSO/SAML implementation (replaces PoC stubs) * MCP entitlement edge for the TypeScript MCP server * Fix repo license-of-record (currently NOASSERTION) — owner decision Related to #847 Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build-without-submodule.yml | 140 +++++ .gitmodules | 3 + docker-compose.yml | 6 + docs/dev/ENTERPRISE_LOCAL_DEV.md | 181 ++++++ docs/memory/requirements.md | 67 +++ docs/planning/ENTERPRISE_ARCHITECTURE.md | 158 ++++++ .../planning/OSS_ENTERPRISE_SPLIT_RESEARCH.md | 521 ++++++++++++++++++ src/backend/dependencies.py | 42 ++ src/backend/enterprise | 1 + src/backend/main.py | 24 + src/backend/routers/settings.py | 6 + src/backend/services/entitlement_service.py | 105 ++++ tests/unit/test_847_entitlement_seam.py | 197 +++++++ 13 files changed, 1451 insertions(+) create mode 100644 .github/workflows/build-without-submodule.yml create mode 100644 docs/dev/ENTERPRISE_LOCAL_DEV.md create mode 100644 docs/planning/ENTERPRISE_ARCHITECTURE.md create mode 100644 docs/planning/OSS_ENTERPRISE_SPLIT_RESEARCH.md create mode 160000 src/backend/enterprise create mode 100644 src/backend/services/entitlement_service.py create mode 100644 tests/unit/test_847_entitlement_seam.py diff --git a/.github/workflows/build-without-submodule.yml b/.github/workflows/build-without-submodule.yml new file mode 100644 index 000000000..2ad03105c --- /dev/null +++ b/.github/workflows/build-without-submodule.yml @@ -0,0 +1,140 @@ +name: build-without-submodule + +# #847 Phase 0 — guard against the OSS-only break. +# +# The public Trinity backend conditionally imports the private +# enterprise submodule via `try: from enterprise import register_enterprise` +# in `src/backend/main.py`. This job proves the conditional import works +# correctly when the submodule is **absent** — i.e. when a customer +# without enterprise repo access clones the public repo. +# +# Without this gate, a future change to the enterprise loader could +# accidentally hard-require the submodule and break OSS-only deployments +# on the next release. The first signal would be customers reporting +# `ModuleNotFoundError: No module named 'enterprise'` after `git pull`. +# +# The job runs: +# 1. Checkout without submodules +# 2. Build the backend Docker image +# 3. Start it on a temp network +# 4. Assert /health responds and /api/settings/feature-flags returns +# `enterprise_features: []` +# +# Triggers: every PR + every push to dev/main. Issue #847. + +on: + pull_request: + branches: [dev, main] + push: + branches: [dev, main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: build-without-submodule-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-boot: + name: backend boots without enterprise submodule + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout WITHOUT submodules + uses: actions/checkout@v4 + with: + submodules: false # explicit — the whole point of this job + fetch-depth: 1 + persist-credentials: false + + - name: Confirm enterprise submodule is absent + run: | + # Submodule mount point exists in tree but no checked-out content + # when --submodules=false. + if [ -f src/backend/enterprise/__init__.py ]; then + echo "::error::src/backend/enterprise/__init__.py present — checkout pulled the submodule. The job must run without it." + exit 1 + fi + echo "OK: enterprise submodule absent as expected" + + - name: Generate ephemeral secrets for boot + run: | + { + echo "SECRET_KEY=$(openssl rand -hex 32)" + echo "ADMIN_PASSWORD=$(openssl rand -hex 16)" + echo "REDIS_PASSWORD=$(openssl rand -hex 24)" + echo "REDIS_BACKEND_PASSWORD=$(openssl rand -hex 24)" + echo "CREDENTIAL_ENCRYPTION_KEY=$(openssl rand -hex 32)" + echo "INTERNAL_API_SECRET=$(openssl rand -hex 32)" + } > .env + + - name: Build backend image + run: docker compose build backend + + - name: Boot the stack + run: | + docker compose up -d backend redis vector + # Wait up to 60s for backend /health + for i in $(seq 1 30); do + if curl -sf http://localhost:8000/health > /dev/null; then + echo "Backend healthy after ${i}x2s" + break + fi + sleep 2 + done + curl -sf http://localhost:8000/health || ( + echo "::error::Backend never became healthy" + docker logs trinity-backend --tail 100 + exit 1 + ) + + - name: Assert OSS-only feature flags + run: | + ADMIN_PASSWORD=$(grep '^ADMIN_PASSWORD=' .env | cut -d= -f2) + TOKEN=$(curl -sf -X POST http://localhost:8000/api/token \ + -d "username=admin&password=${ADMIN_PASSWORD}" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") + FLAGS=$(curl -sf -H "Authorization: Bearer $TOKEN" \ + http://localhost:8000/api/settings/feature-flags) + echo "feature-flags response: $FLAGS" + ENT=$(echo "$FLAGS" | python3 -c "import sys,json; print(json.load(sys.stdin)['enterprise_features'])") + if [ "$ENT" != "[]" ]; then + echo "::error::Expected enterprise_features=[] in OSS-only build, got: $ENT" + exit 1 + fi + echo "OK: enterprise_features=[] as expected" + + - name: Assert SSO router not mounted + run: | + ADMIN_PASSWORD=$(grep '^ADMIN_PASSWORD=' .env | cut -d= -f2) + TOKEN=$(curl -sf -X POST http://localhost:8000/api/token \ + -d "username=admin&password=${ADMIN_PASSWORD}" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") + # Without the enterprise submodule, the /api/enterprise/sso router + # is never mounted — FastAPI returns 404 for any path under it. + CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer $TOKEN" \ + http://localhost:8000/api/enterprise/sso/providers) + if [ "$CODE" != "404" ]; then + echo "::error::Expected 404 from /api/enterprise/sso/providers in OSS-only build, got HTTP $CODE" + exit 1 + fi + echo "OK: /api/enterprise/sso/providers returns 404 as expected" + + - name: Confirm OSS-only log line was emitted + run: | + # The main.py except branch logs an informational message we + # can grep for. Catches a future regression where the + # ImportError is silently swallowed without a log. + if ! docker logs trinity-backend 2>&1 | grep -q "Trinity Enterprise submodule not present"; then + echo "::error::Expected 'Trinity Enterprise submodule not present' log line, did not find it" + docker logs trinity-backend --tail 60 + exit 1 + fi + echo "OK: OSS-only mode logged" + + - name: Tear down + if: always() + run: docker compose down -v --remove-orphans diff --git a/.gitmodules b/.gitmodules index f90a0a22a..6398a43c6 100644 --- a/.gitmodules +++ b/.gitmodules @@ -3,3 +3,6 @@ url = https://github.com/Abilityai/trinity-dev.git update = checkout fetchRecurseSubmodules = true +[submodule "src/backend/enterprise"] + path = src/backend/enterprise + url = git@github.com:Abilityai/trinity-enterprise.git diff --git a/docker-compose.yml b/docker-compose.yml index 7e67fda05..12bfdab1f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -92,6 +92,12 @@ services: # owned by the developer UID, not container UID 1000, so the writes # fail. macOS Docker Desktop masks the failure; set this for parity. - PYTHONDONTWRITEBYTECODE=1 + # #847 — force OSS-only mode even when the enterprise submodule + # is mounted. With this set, EntitlementService.is_entitled() + # returns False for every feature, and the /api/settings/feature-flags + # `enterprise_features` list is empty. Default (unset = 0): allow + # entitlements based on license (Phase 0 stub: all-entitled). + - TRINITY_OSS_ONLY=${TRINITY_OSS_ONLY:-0} volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - ./src/backend:/app diff --git a/docs/dev/ENTERPRISE_LOCAL_DEV.md b/docs/dev/ENTERPRISE_LOCAL_DEV.md new file mode 100644 index 000000000..b99675a15 --- /dev/null +++ b/docs/dev/ENTERPRISE_LOCAL_DEV.md @@ -0,0 +1,181 @@ +# Enterprise modules — local development + +Clone-to-running guide for working on the closed-source enterprise +modules (`Abilityai/trinity-enterprise`) alongside the public Trinity +backend. Target: **15 minutes from `git clone` to a running +`/api/enterprise/sso/providers` endpoint.** + +> **Access required**: read access to the private repo +> `Abilityai/trinity-enterprise`. Ask the project owner for an +> invite if you don't have it yet. + +## TL;DR + +```bash +git clone --recurse-submodules git@github.com:abilityai/trinity.git +cd trinity +./scripts/deploy/start.sh +curl http://localhost:8000/api/enterprise/sso/providers +# → [] +``` + +That's it. The conditional loader in `src/backend/main.py` picks up +the submodule automatically. + +## Step-by-step + +### 1. Clone with submodules (1 min) + +```bash +git clone --recurse-submodules git@github.com:abilityai/trinity.git +cd trinity +``` + +If you already cloned without `--recurse-submodules`, pull the +submodules in: + +```bash +git submodule update --init --recursive +``` + +`.gitmodules` mounts two private submodules: + +| Submodule | Path | Purpose | +|---|---|---| +| `.claude` | `.claude/` | Dev-methodology skills (`/sprint`, `/cso`, `/autoplan`, etc.) | +| `src/backend/enterprise` | `src/backend/enterprise/` | Enterprise compliance modules (SSO, SCIM, SIEM) | + +Both use SSH (`git@github.com:...`). If you only have HTTPS auth +configured, add an SSH override: + +```bash +git config --global url."git@github.com:".insteadOf "https://github.com/" +git submodule sync --recursive +git submodule update --init --recursive +``` + +### 2. (Recommended) Auto-sync on branch switch (10 sec) + +Without this, switching branches leaves the submodules stale and +imports fail silently: + +```bash +git config submodule.recurse true +``` + +### 3. Start the stack (5 min, mostly Docker build) + +```bash +./scripts/deploy/start.sh +``` + +This boots Redis, Vector, the backend (which conditionally loads the +enterprise submodule), MCP server, and the frontend. + +### 4. Verify enterprise is wired (10 sec) + +```bash +# Endpoint exists and responds +curl http://localhost:8000/api/enterprise/sso/providers +# → [] + +# Feature flags expose the entitled enterprise list +TOKEN=$(curl -s -X POST http://localhost:8000/api/token \ + -d 'username=admin&password='"$ADMIN_PASSWORD" | jq -r .access_token) +curl -s -H "Authorization: Bearer $TOKEN" \ + http://localhost:8000/api/settings/feature-flags | jq .enterprise_features +# → ["sso", "scim", "siem"] +``` + +If you see those responses, the submodule is mounted, the conditional +import worked, and the entitlement seam reports all features enabled +(the Phase 0 stub). + +## Working on the enterprise repo + +```bash +cd src/backend/enterprise +git checkout main # submodules default to detached HEAD +# … make changes … +git commit -m "feat(sso): ..." +git push origin main +``` + +Then, back in the public repo, **bump the submodule pointer** so other +developers see your changes: + +```bash +cd ../../../ # back to trinity root +git add src/backend/enterprise +git commit -m "chore: bump enterprise submodule" +git push +``` + +## Forcing OSS-only mode (testing the deny path) + +```bash +echo "TRINITY_OSS_ONLY=1" >> .env +docker compose up -d --force-recreate backend +# Now enterprise features are gated (403) even though the submodule +# is mounted. Useful for testing the OSS UX without unmounting. + +# Revert +sed -i '/^TRINITY_OSS_ONLY=/d' .env +docker compose up -d --force-recreate backend +``` + +## Running Trinity OSS-only (no submodule access) + +Cloning without enterprise access is the **default** OSS experience: + +```bash +git clone https://github.com/abilityai/trinity.git +cd trinity +git submodule update --init .claude # explicit — skip enterprise +./scripts/deploy/start.sh +``` + +The conditional `try: from enterprise import register_enterprise` in +`main.py` raises `ImportError`, the `except` branch logs an +informational message, and Trinity runs as OSS. Confirm: + +```bash +docker logs trinity-backend 2>&1 | grep -i enterprise +# → INFO Trinity Enterprise submodule not present — OSS-only build +``` + +## CI: testing without the submodule + +`.github/workflows/build-without-submodule.yml` boots the backend with +the submodule absent and asserts the conditional import doesn't break +the boot. Run it manually with: + +```bash +gh workflow run build-without-submodule.yml +``` + +The job exists specifically so a forgotten access revocation +(submodule URL no longer reachable) is caught in CI rather than at a +customer's first OSS-only clone. + +## Troubleshooting + +**Submodule clone fails with "Permission denied (publickey)"** — your +GitHub SSH key isn't loaded. Run `ssh-add ~/.ssh/id_ed25519` or +configure the URL rewrite shown in Step 1. + +**`curl /api/enterprise/sso/providers` returns 404** — the submodule +didn't init. Run `git submodule status` — the line for +`src/backend/enterprise` should show a real SHA, not `-XXXX`. If it +shows `-XXXX`, run `git submodule update --init --recursive`. + +**Enterprise endpoint returns 403 "not licensed"** — `TRINITY_OSS_ONLY=1` +is set in your `.env`. Either remove it or change to `0`, then +`docker compose up -d --force-recreate backend`. + +## See also + +- `docs/planning/ENTERPRISE_ARCHITECTURE.md` — decision record +- `docs/planning/OSS_ENTERPRISE_SPLIT_RESEARCH.md` — full research +- `Abilityai/trinity-enterprise/README.md` — private repo overview +- Issue #847 — the spike that produced this scaffolding diff --git a/docs/memory/requirements.md b/docs/memory/requirements.md index 57947e6a2..75f24032e 100644 --- a/docs/memory/requirements.md +++ b/docs/memory/requirements.md @@ -2358,6 +2358,73 @@ Standalone mobile-friendly admin page for managing agents on the go. Designed as (expressed as event chains between independent per-agent pipelines); GUI editor for `pipeline.yaml`; persisting pipeline state in Trinity's database. +## 35. Enterprise Edition Architecture (#847) + +### 34.1 Open-Core Seam — Private Submodule Integration (#847 Phase 0) +- **Status**: ✅ Implemented (2026-05-21) +- **GitHub Issue**: #847 +- **Description**: Phase 0 of the enterprise edition split — establishes + the seam in the public Trinity backend for loading closed-source + compliance modules (SSO, SCIM, SIEM) from a private git submodule + at `src/backend/enterprise/` pointing to + `Abilityai/trinity-enterprise`. No license code yet; the + `EntitlementService` is a stub that returns True for every + feature_id, gated by `TRINITY_OSS_ONLY` for testing the deny path. +- **Decision record**: `docs/planning/ENTERPRISE_ARCHITECTURE.md` +- **Long-form research**: `docs/planning/OSS_ENTERPRISE_SPLIT_RESEARCH.md` +- **Local-dev guide**: `docs/dev/ENTERPRISE_LOCAL_DEV.md` +- **Key Features**: + - `EntitlementService` (`src/backend/services/entitlement_service.py`) + — Phase 0 stub returning all-entitled by default. Honours + `TRINITY_OSS_ONLY=1` for forced OSS-only mode (returns False for + every check). Module-level singleton + `_set_for_testing` seam. + - `requires_entitlement(feature_id)` (`src/backend/dependencies.py`) + — FastAPI dependency factory mirroring `require_role`. Raises + HTTP 403 with the feature_id in the detail string. Lazy-imports + the service so tests can swap singletons. + - Conditional submodule loader in `src/backend/main.py` — + `try: from enterprise import register_enterprise; + register_enterprise(app) except ImportError: pass`. OSS-only + builds (no submodule) silently no-op with an informational log. + Idempotent via `app.state.enterprise_registered`. + - `/api/settings/feature-flags` extended with + `enterprise_features: list[str]` — empty in OSS mode, populated + when entitled. UI uses this to hide enterprise-only tabs cleanly + without server-side conditional rendering. + - `.gitmodules` entry for `src/backend/enterprise` via SSH + (`git@github.com:Abilityai/trinity-enterprise.git`). + - `docker-compose.yml` env pass-through for `TRINITY_OSS_ONLY`. + - CI workflow `.github/workflows/build-without-submodule.yml` — + boots the backend with the submodule absent and asserts: + `/health` responds, `/api/settings/feature-flags` returns + `enterprise_features: []`, `/api/enterprise/sso/providers` + returns 404, and the OSS-only log line is emitted. Catches + regressions where the conditional import accidentally becomes + hard-required. +- **Private repo (Abilityai/trinity-enterprise)**: + - `__init__.py` — `register_enterprise(app)` single entry point + - `sso/router.py` — `/api/enterprise/sso/{providers,login/{id}}` + stubs gated by `requires_entitlement("sso")`. `GET /providers` + returns the in-process registry (empty by default); + `POST /login/{id}` returns 501 "PoC stub" or 404 for unknown id. + - `sso/providers.py` — `SSOProvider` ABC (provider_id, + display_name, protocol, begin_login) + `StubProvider` for the + PoC. + - `pyproject.toml`, `LICENSE` (proprietary), `README.md`. +- **Out of scope (separate follow-ups)**: + - Phase 1: Ed25519-signed license token, verify path, admin + License UI, grace + clock-tamper handling. + - Phase 2: Extract `audit_log` into the submodule as the first + real enterprise module. + - Phase 3: Prove the "core-primitive + enterprise-knob" pattern + via #834 (recovery API in OSS, license-capped retention in enterprise). + - Phase 4: Real SSO/SAML implementation (replaces PoC stubs). + - MCP entitlement edge (`GET /api/internal/entitlements` polled by + the TypeScript MCP server). + - Fixing the repo's license-of-record (currently `NOASSERTION`). +- **Tunables (env)**: + - `TRINITY_OSS_ONLY` (`0`/`1`, default `0`) — force OSS-only mode + regardless of submodule presence. --- diff --git a/docs/planning/ENTERPRISE_ARCHITECTURE.md b/docs/planning/ENTERPRISE_ARCHITECTURE.md new file mode 100644 index 000000000..f29c779b8 --- /dev/null +++ b/docs/planning/ENTERPRISE_ARCHITECTURE.md @@ -0,0 +1,158 @@ +# Enterprise Architecture Decision + +> **Status**: Decided 2026-05-21. PoC implemented (issue #847). +> Decision record condensed from +> `docs/planning/OSS_ENTERPRISE_SPLIT_RESEARCH.md` (521 lines, the +> full investigation and tradeoffs). + +## Decision + +Trinity adopts an **open-core** model: one public codebase +(`abilityai/trinity`), with closed-source compliance modules in a +**private git submodule** at `src/backend/enterprise/` pointing to +`Abilityai/trinity-enterprise`. The public backend loads the submodule +via a conditional import; clones without enterprise access boot as +OSS-only with no code changes. + +### Six load-bearing decisions + +| # | Decision | One-line why | +|---|---|---| +| 1 | **Open-core via private submodule** at `src/backend/enterprise/` | Reuses the trusted `.claude` submodule pattern. One repo per edition diverges; one repo + submodule converges. | +| 2 | **One `EntitlementService` singleton** in `services/entitlement_service.py` | Centralises feature-gate logic. Routers call `requires_entitlement(feature_id)` instead of scattered `if`. | +| 3 | **Offline Ed25519-signed license** (Phase 1, not in this PR) | "Sovereign infra on your own hardware" pitch breaks if Trinity has to phone home; air-gapped buyers refuse phone-home. | +| 4 | **Tier as Community / Team / Enterprise** | Charge for governance / scale / compliance — what an *org* pays for. Don't paywall the adoption funnel. | +| 5 | **Accept soft enforcement** | The moat is signed updates + support + the legal license, not obfuscation of code. | +| 6 | **Fix the repo's license-of-record** before Phase 1 | Repo currently shows `license: NOASSERTION`. Open-core is a *legal* structure before a code one. | + +### What lives where + +``` +abilityai/trinity (public, MIT/Apache after decision #6) +├── src/backend/ +│ ├── main.py conditional import + register_enterprise(app) +│ ├── dependencies.py requires_entitlement(feature_id) — Phase 0 seam +│ ├── services/ +│ │ └── entitlement_service.py EntitlementService (Phase 0 stub; Phase 1 license) +│ └── enterprise/ ← submodule mount point +│ └── (populated by submodule init — see below) +├── docs/planning/ +│ ├── ENTERPRISE_ARCHITECTURE.md this file +│ └── OSS_ENTERPRISE_SPLIT_RESEARCH.md long-form research +└── docs/dev/ + └── ENTERPRISE_LOCAL_DEV.md 15-min onboarding guide + +Abilityai/trinity-enterprise (private, proprietary) +├── __init__.py register_enterprise(app) entry point +├── sso/ #847 PoC (router + provider ABC + stubs) +├── scim/ planned (#???) +├── siem/ planned (#???) +└── LICENSE commercial / proprietary +``` + +## Why a submodule, not a Python package + +Three options compared in the research: + +| Option | Local DX | CI complexity | Distribution | Verdict | +|---|---|---|---|---| +| **Git submodule** | clone-with-submodule, no extra step | OSS CI works with or without submodule | URL access controls who gets it | ✅ chosen — same pattern as `.claude` | +| Private PyPI / GitHub Packages | `pip install trinity-enterprise` extra | needs Packages auth on every CI run | requires customer to know about pkg index | ✗ added DX friction for the dev-loop | +| Plugin hook (runtime download) | needs a registration script | brittle in air-gapped deployments | hardest to gate | ✗ doesn't match "sovereign infra" pitch | + +The submodule approach lets a developer with org access work on the +enterprise code in the same checkout as Trinity — no `pip install -e` +dance, no symlink hacks. Compare CI cost: the `build-without-submodule` +job below proves the conditional import works. + +## The seam (what landed in this PR — #847 Phase 0) + +**`src/backend/services/entitlement_service.py`** +- `EntitlementService.is_entitled(feature_id) -> bool` — returns True + by default (Phase 0 stub). `TRINITY_OSS_ONLY=1` env flips every check + to False for compliance lockdown or testing the deny path. +- `EntitlementService.list_entitled_features() -> list[str]` — drives + UI tab visibility via `/api/settings/feature-flags`. +- Module-level singleton `entitlement_service`; `_set_for_testing` test seam. + +**`src/backend/dependencies.py:requires_entitlement(feature_id)`** +- Dependency factory mirroring `require_role`. Raises HTTP 403 with a + message naming the missing feature, so the UI can surface a "license + required" toast and the operator can correlate with `system_settings`. + +**`src/backend/main.py`** +- Conditional `try: from enterprise import register_enterprise; register_enterprise(app) except ImportError`. +- Logs which mode it's in (registered / OSS-only) for ops visibility. + +**`src/backend/routers/settings.py:get_public_feature_flags`** +- Adds `enterprise_features: list[str]` to the response. Empty list == + OSS-only (UI hides enterprise tabs). + +**`.gitmodules`** +- New `src/backend/enterprise` submodule entry pointing at the private repo via SSH. + +**`docker-compose.yml`** +- Pass-through for `TRINITY_OSS_ONLY` env var. + +## What the private repo holds (`Abilityai/trinity-enterprise`) + +PoC scope (this PR): + +| File | Purpose | +|---|---| +| `__init__.py` | `register_enterprise(app)` — single integration entry | +| `sso/router.py` | `/api/enterprise/sso/{providers,login/{id}}` — stubs, gated by `requires_entitlement("sso")` | +| `sso/providers.py` | `SSOProvider` ABC + `StubProvider` for the PoC registry | +| `pyproject.toml` | Metadata (no pip-install mode yet — submodule mount only) | +| `LICENSE` | Proprietary | + +## How CI handles "build without submodule" + +Workflow `.github/workflows/build-without-submodule.yml` boots the +backend image with the submodule **absent** and asserts: + +1. Container starts cleanly +2. `GET /api/settings/feature-flags` returns `enterprise_features: []` +3. `GET /api/enterprise/sso/providers` returns 404 (router not mounted) + +This proves the conditional import doesn't break OSS-only deployments +when the submodule URL access is revoked or the submodule is unchecked. + +## What's NOT in this PR (open follow-ups) + +1. **Phase 1 — License mechanism**: Ed25519 signing CLI, license verify + path, admin License UI, grace + clock-tamper handling. The + `EntitlementService` stub is the seam for this. +2. **Phase 2 — Extract a clean leaf**: move audit log into the + enterprise submodule (highest enterprise value, cleanest boundary). +3. **Phase 3 — Prove the "core-primitive + enterprise-knob" pattern** + via #834 (soft-delete OSS core + license-capped retention as + enterprise knob). +4. **Phase 4 — Build SSO/SAML** for real (replaces the PoC stubs). +5. **MCP entitlement edge**: `GET /api/internal/entitlements` polled by + the TypeScript MCP server so MCP-tool-layer gates also see the + license state. +6. **License-of-record fix**: `LICENSE` file at repo root replacing + `NOASSERTION`. Owner decision, not engineering. + +## Risks accepted + +- **Forks of the OSS repo can replace `EntitlementService` with an + always-True implementation.** The defence is licensing law (signed + contract + commercial license terms), signed releases, and access to + support — not obfuscation. Documented in research §7. +- **Per-process semaphore-style state lives in the singleton** — + acceptable for a single-host backend; horizontal scaling needs a + rethink (none currently planned). +- **Drift risk between `AUTH_INDICATORS`-style duplicated lists in the + scheduler container** — already exists for `is_auth_failure` (#904); + the entitlement service is read once at boot so it doesn't add new + drift surface. + +--- + +For the full investigation, alternatives considered, gating-mechanism +options, stress test against 8 other open issues, and the +four-patterns refinement (clean leaf / core-primitive + knob / data +capture / cross-cutting integration), see +`docs/planning/OSS_ENTERPRISE_SPLIT_RESEARCH.md`. diff --git a/docs/planning/OSS_ENTERPRISE_SPLIT_RESEARCH.md b/docs/planning/OSS_ENTERPRISE_SPLIT_RESEARCH.md new file mode 100644 index 000000000..efbbe85fa --- /dev/null +++ b/docs/planning/OSS_ENTERPRISE_SPLIT_RESEARCH.md @@ -0,0 +1,521 @@ +# Research: Splitting Trinity into Open-Source and Enterprise Editions + +> **Status**: Research / opinion. Not a committed plan. Branch: +> `research/oss-enterprise-edition-split`. +> **Author**: investigation requested 2026-05-18. +> **Scope**: how to split the codebase, where to gate features, how to +> control *who* and *for how long* can use enterprise features, and an +> honest recommendation with risks. + +--- + +## TL;DR + +**The recommendation in one sentence:** keep one public codebase, move +enterprise code into a private git submodule (the pattern Trinity +already runs for `.claude`), gate it at runtime through a single +`EntitlementService` driven by an offline signed license — and fix the +repo's undefined license *before* writing any of it. + +### The six decisions + +| # | Decision | Why | +|---|---|---| +| 1 | **Open-core, not a fork.** Enterprise code = a private submodule at `src/backend/enterprise/` + `src/frontend/src/enterprise/`. | Reuses the trusted `.claude` submodule pattern. Two diverging repos is how open-core dies. | +| 2 | **One `EntitlementService`, not scattered `if`s.** It plugs into seams that already exist: the FastAPI dependency layer (`require_role`), the settings resolver (`settings_service.py`), and `/api/settings/feature-flags`. | The hooks are already there. A central service keeps 53 routers clean. | +| 3 | **License = offline Ed25519-signed token.** No license server. Claims: customer, edition, feature set, seat/agent caps, `expires_at`, grace window. | "Sovereign infra on your own hardware" — phone-home breaks the pitch *and* air-gapped buyers. | +| 4 | **Tier it Community / Team / Enterprise**, not free/paid binary. Keep ≥1 channel integration free. | Charge for governance / scale / compliance — what an *org* pays for. Don't paywall the adoption funnel. | +| 5 | **Accept soft enforcement.** Don't build DRM. | The moat is signed updates + support + the legal license, not obfuscation of readable code. | +| 6 | **Fix the license-of-record first.** Repo currently reports `license: NOASSERTION`. | Open-core is a *legal* structure before a code one. Blocks everything (see §6). | + +### What the stress test sharpened (see §8) + +Testing the approach against 8 other open issues found it **holds but is +not one pattern — it is four**, and exposed two real gaps the rest of +this doc now accounts for: + +- **The MCP server can't see the EntitlementService.** It's a separate + TypeScript process. Any feature gated at the MCP-tool layer (e.g. + #846, A2A) needs a small read-only entitlement endpoint the MCP + server polls. *This was the one outright break.* +- **Some things must never be gated.** Auth, security, schema/data + capture, and CI/build are explicit non-gates — without naming them, + the model gets misapplied (the inventory pass already over-classified + Slack). + +### Do this first + +1. **Owners decide** (not engineering): OSS license (Apache vs + source-available), per-instance vs per-seat, tier of each ambiguous + feature. +2. **Build the seam in the public repo, move nothing yet**: + `EntitlementService` (stub = all-entitled) + the MCP entitlement + endpoint + conditional `register_enterprise()` + a CI job that builds + with the submodule absent. +3. **Extract one true leaf first** (audit log), then use **#834** to + prove the harder "core-primitive + enterprise-knob" pattern. + +--- + +## 1. Current state — what the investigation found + +Three independent code investigations (gating infra, feature inventory, +build/packaging) converge on the same picture: + +**There is no edition/license/tier/premium concept anywhere.** Grep +across `src/`, `config/`, `docs/` found nothing. The split is greenfield. + +**But the gating primitives all exist already:** + +| Primitive | Location | What it gives us | +|---|---|---| +| Role hierarchy | `src/backend/dependencies.py` — `ROLE_HIERARCHY = ["user","operator","creator","admin"]`, `require_role(min)`, `require_admin` | Per-endpoint dependency gate. The natural place to add `requires_entitlement(...)`. | +| Centralized settings | `src/backend/services/settings_service.py` — single resolver, DB→env→default, TTL cache precedent (`_PLATFORM_MODEL_CACHE_TTL`) | One place to resolve "is feature X licensed?" with caching already proven. | +| Feature-flags API | `routers/settings.py` `GET /api/settings/feature-flags` (`session_tab_enabled`, `workspace_available`, `voice_available`) consumed by the frontend on load | Frontend already conditionally renders off a backend flag fetch. Enterprise UI hiding is a 1-flag extension. | +| Settings store | `system_settings` (key/value), admin-writable via `PUT /api/settings/{key}`, migrations idempotent | A natural home for the license blob and cached entitlement state. | +| Quota enforcement | `settings_service.py` `get_agent_quota_for_role()` enforced in `agent_service/crud.py` (429 `QUOTA_EXCEEDED`) | Proves the codebase already does numeric caps with a clean error contract — reusable shape for seat/agent license caps. | +| Subscription model | `subscription_credentials` table, `agent_ownership.subscription_id` FK | Prior art for "an entitlement attached to an owner," though it's about Claude tokens, not licensing. | +| Private submodule | `.gitmodules` → `.claude` = private `Abilityai/trinity-dev`, auto-synced | **The distribution precedent for the whole strategy.** | + +**Weaknesses that matter for the split:** + +- **Static router registration.** `main.py` imports all ~53 routers + unconditionally. Enterprise routers must become *conditionally + registered*. This is the single biggest code change. +- **Distributed gating.** No central checkpoint. If we don't introduce + one `EntitlementService`, the split will smear license `if`s across + 53 routers and rot. +- **Backend Dockerfile copies explicitly** (`COPY routers/ services/ + ...`). An enterprise build needs the submodule present at build time; + the OSS build must work *without* it. +- **No SSO/SAML/SCIM exists.** The single most-requested enterprise + auth feature is not built. The split plan should reserve the seam, + not pretend it exists. + +--- + +## 2. Distribution model — the decision that constrains everything + +### Options considered + +| Model | What it is | Verdict | +|---|---|---| +| **A. Two repos** | Public OSS repo, private full repo, cherry-pick between | ❌ Highest maintenance; merge drift; the classic open-core failure mode. | +| **B. Single repo, edition `if`s** | All code public, enterprise behind a license check | ❌ Enterprise source is fully public → zero IP protection, trivial to unlock. Only viable if you *don't care* about source secrecy (some companies don't — see "Honest take" §7). | +| **C. Open-core + private submodule** ✅ | Public core repo; `src/backend/enterprise/` and `src/frontend/src/enterprise/` are a private submodule (like `.claude`) | ✅ **Recommended.** One history per layer, clean public/private boundary, reuses a shipped & trusted pattern, OSS build works with the submodule absent. | +| **D. Plugin marketplace** | Enterprise features as separately-installed signed plugins | ⚠️ Cleanest long-term, heaviest to build now. Good *evolution* of C, not a starting point. | + +### Recommended: Model C + +``` +trinity/ (PUBLIC — open-source edition) +├── src/backend/ +│ ├── main.py ← conditional enterprise registration +│ ├── routers/ services/ ... ← core, OSS +│ └── enterprise/ ← GIT SUBMODULE → Abilityai/trinity-enterprise (PRIVATE) +│ ├── routers/ (audit, canary, sso, fleet-gov, ...) +│ ├── services/ +│ └── entitlements/ ← license verify + EntitlementService +├── src/frontend/src/ +│ └── enterprise/ ← submodule, enterprise Vue views/components +└── .gitmodules ← adds the enterprise submodule (private URL) +``` + +- **OSS clone**: submodule not initialized → `src/backend/enterprise/` + absent → `main.py` registers core routers only → fully functional + Community edition. This must be a first-class, tested path (CI job + that builds with the submodule *removed*). +- **Enterprise build**: `git submodule update --init` pulls the private + repo → enterprise routers register → license gates them at runtime. +- **`main.py` change** (the crux): + + ```python + # core routers: unconditional (unchanged) + app.include_router(agents_router) + ... + # enterprise: present only when the submodule is checked out + try: + from enterprise import register_enterprise # submodule package + register_enterprise(app, entitlements) # gates per-feature + except ModuleNotFoundError: + logger.info("Community edition — enterprise modules absent") + ``` + + Two independent locks: **code presence** (submodule) *and* **runtime + entitlement** (license). An OSS user never has the code; an + enterprise user without a valid license has the code but it 403s. + +> Precedent strength: the project already documents the `.claude` +> submodule setup (`git submodule update --init --recursive`, +> `submodule.recurse true`, `fetchRecurseSubmodules`). The enterprise +> submodule is the *same operational story* the team already runs. + +--- + +## 3. The gating architecture + +### 3.1 One service, three consumers + +Add `enterprise/entitlements/service.py` → `EntitlementService`: + +```python +class EntitlementService: + def is_entitled(self, feature_key: str) -> bool: ... + def assert_entitled(self, feature_key: str) -> None: # raises 402/403 + def status(self) -> LicenseStatus: # for /feature-flags + admin UI + def caps(self) -> dict # seat/agent numeric limits +``` + +Resolution order (mirrors the proven `settings_service` pattern, same +TTL-cache shape): + +1. Verified license token (signed, see §4) → entitled feature set + caps +2. No/expired license → Community feature set + grace handling (§5) +3. Cached for `ENTITLEMENT_CACHE_TTL` (e.g. 300s) to avoid per-request + crypto; invalidated on license update (same hook as + `platform_default_model` cache invalidation). + +**Three consumers, no fourth:** + +1. **Backend endpoints** — a dependency twin of `require_role`: + + ```python + def requires_entitlement(feature_key: str): + def dep(user: User = Depends(get_current_user)): + entitlements.assert_entitled(feature_key) # 402 if not licensed + return user + return dep + # usage on an enterprise router: + router = APIRouter(dependencies=[Depends(requires_entitlement("audit_log"))]) + ``` + + Router-level `dependencies=[...]` means **one line per enterprise + router**, not per endpoint — minimal, auditable, no smear. + +2. **Frontend** — extend `GET /api/settings/feature-flags` with an + `entitlements: {audit_log: true, sso: false, ...}` block. The + frontend already fetches and conditionally renders off this exact + endpoint; enterprise nav/routes hide when false. (UI hiding is UX, + not security — the backend dependency is the real gate.) + +3. **MCP server** — enterprise MCP tools (e.g. `monitoring.ts`, + `subscriptions.ts`) call backend endpoints that are already gated; + they fail closed with the backend's 402. Optionally filter the tool + list by entitlement so they don't even advertise. + +### 3.2 Failure semantics — pick deliberately + +| Mode | Behavior on missing/expired license | Use when | +|---|---|---| +| **Hard block** | Enterprise endpoints 402; UI hidden | Default for never-licensed | +| **Grace** | Full function + visible "license expired, N days" banner; then degrade | Expiry of a *paying* customer — don't break prod on renewal lag | +| **Degrade-to-Community** | Enterprise silently off, core keeps working | Strongly preferred over "platform won't boot" | + +**Never make the platform refuse to start on a license problem.** A +sovereign self-host that bricks itself on an expired token is a +support nightmare and a trust violation. Core must always run. + +--- + +## 4. Licensing — *who* and *for how long* + +### 4.1 Mechanism: offline-verifiable signed license token + +- **Format**: compact JWS/PASETO-style token, **Ed25519**-signed. + Anthropic-style: a base64 blob the customer pastes into + Settings → License (admin-only `PUT /api/settings/license`, stored in + `system_settings` — precedent exists — or a dedicated `license` + table for auditability). +- **Public key** baked into the enterprise submodule. Verification is + pure-offline; no network required. (Air-gapped customers are a + first-class case for "on your own hardware.") +- **Claims**: + + ```jsonc + { + "lic_id": "uuid", + "customer": "ACME GmbH", + "edition": "enterprise", // community | team | enterprise + "features": ["audit_log","sso","canary","fleet_gov", ...], + "caps": { "max_agents": 200, "max_seats": 50 }, + "issued_at": "2026-05-18T00:00:00Z", + "not_before": "2026-06-01T00:00:00Z", + "expires_at": "2027-06-01T00:00:00Z", + "grace_days": 30, + "support_tier": "gold" + } + ``` + +- **"For how long"** = `expires_at` + `grace_days`. After expiry: grace + window with loud banners (reuse the operator-queue / notification + surfaces that already exist), then degrade-to-Community. Clock-tamper + resistance: persist a monotonic "max timestamp ever seen" in + `system_settings`; if wall clock < that, treat as tamper → grace. +- **"Who"** = identity model decision (§4.2). + +### 4.2 What does a "seat" mean here? (decide explicitly) + +Trinity is self-hosted and multi-user (`users` table, 4-tier roles). +Options for the licensed unit: + +1. **Per instance** — one license = one deployment, unlimited users. + Simplest, matches "sovereign box," easiest to sell, hardest to + price-discriminate. **Recommended starting point.** +2. **Per seat** — cap on `users` rows (or active users / 30d). Enforce + at user-create, same shape as the existing `QUOTA_EXCEEDED` 429. + More revenue granularity, more friction, more support load. +3. **Per agent** — cap on owned agents. The codebase *already* enforces + per-role agent quotas; a license-driven `caps.max_agents` is a + near-trivial extension of `get_agent_quota_for_role()`. + +Recommendation: **per-instance editions + a soft agent cap** to start +(reuses existing quota machinery, lowest friction), add per-seat later +if sales needs it. Don't build seat metering before a customer asks. + +### 4.3 Issuance & revocation + +- **Issuance**: an internal license-signing CLI (private, holds the + Ed25519 private key) — a tiny tool, not a service. Sales/ops runs it. +- **Revocation** (the hard part offline): three pragmatic levers, + ranked by how much they fit the sovereignty pitch: + 1. **Short expiry + renewal** (e.g. 13-month tokens). Natural + revocation = don't reissue. Zero infra. **Primary mechanism.** + 2. **Optional online heartbeat** — opt-in; if the customer allows + egress, daily check against a revocation list. Off by default; + never blocks core. + 3. **Update gating** — revoked customers stop getting signed + releases/support. This is the *real* commercial leverage for + self-hosted software anyway. + +--- + +## 5. What is Community vs Team vs Enterprise + +Open-core principle: **the free tier must be good enough to win the +solo dev and the POC, or the funnel dies.** Charge for what an +*organization* needs and an *individual* never will: governance, +compliance, scale, multi-channel-at-scale, monetization. + +The feature inventory maps cleanly onto three tiers: + +### Community (free, OSS, MIT/Apache — the adoption engine) +Everything needed to run a real single-owner fleet: +- Agent CRUD, chat, **Session/resume**, terminal, files, rename, SSH +- Scheduling + executions + pre-check +- Credentials (CRED-002 encrypted), templates, MCP keys +- Capacity/slots/backlog, cleanup, event bus, WebSocket realtime +- Basic auth (email OTP + admin), the 4-tier role model *as-is* +- Activity timeline, agent logs, single-agent observability +- **One channel integration** (pick Slack — it's the POC magnet) + +### Team (paid, low tier — "more than one human") +Collaboration & light governance: +- Agent sharing & cross-channel access control (#311) +- **All** channel integrations (Telegram, WhatsApp, multi-Slack) +- Tags, saved system views, system manifests (multi-agent deploy) +- Skills library, image gen, avatars, voice/workspace +- Git-sync for GitHub-native agents (#383/#389) + +### Enterprise (top tier — compliance, scale, money) +The things only an org will pay real money for: +- **Platform audit log + hash-chain + retention** (SEC-001 #20) — + the #1 enterprise/compliance ask +- **SSO / SAML / SCIM** — *not built; reserve the seam* (auth.py). + This is the single biggest revenue feature you don't have yet. +- Canary invariant harness (CANARY-001 #411) +- Fleet monitoring (MON-001), Operating Room (OPS-001), fleet + sync-audit (#390), telemetry, OTel observability, log archival +- Soft-delete + retention governance (#834) +- Monetization stack: Nevermined x402 (NVM-001), subscription + credential mgmt (SUB-002) — selling *this* is itself enterprise +- Advanced RBAC beyond the 4 built-in roles; org/workspace model + (also not built — reserve) + +> **Calibration risk to flag:** the agents classified Slack/Telegram/ +> WhatsApp as "enterprise." I disagree for the *first* channel — +> integrations are how people fall in love with the product. Gate the +> *fleet/governance* features hard; keep at least one channel free. + +--- + +## 6. Prerequisite: fix the license of record + +`gh api` reports the repo as `license: NOASSERTION`. Open-core is a +**legal** structure before it is a code structure. Decide and commit, +in this order, *before* coding the gate: + +1. **OSS core license.** Apache-2.0 (patent grant, enterprise-friendly, + permissive — best for adoption) **or** a source-available license + (BSL 1.1 / Elastic-style / Fair-source) if you want to prevent + competitors from reselling Trinity-as-a-service. Permissive vs + source-available is a *business* decision (adoption vs. moat) — it + must be made by the owners, not defaulted. +2. **Commercial/enterprise license** for the private submodule + (proprietary EULA, ties to the signed token). +3. **CLA/DCO** for outside contributors so you retain relicensing + rights on the core. +4. A `LICENSE`, `LICENSE-ENTERPRISE`, and `licensing.md` explaining the + split to users (and what telemetry/heartbeat, if any, exists — be + transparent; the audience is sovereignty-minded). + +--- + +## 7. Honest take / risks + +- **Self-hosted DRM is theatre if over-built.** Source is readable in + the core; the gate is one dependency. A customer *can* patch it. + That's acceptable — your leverage is updates+support+legal, not + bytecode. Invest in the *entitlement model and licensing ops*, cap + the anti-tamper at "honest, clock-aware, hard to do by accident." +- **The sovereignty pitch and license enforcement are in tension.** + Every enforcement choice (phone-home, hard expiry, boot refusal) + spends trust with exactly the buyer Trinity targets. Default to the + least-coercive option that still gets paid: offline tokens, generous + grace, degrade-don't-brick, update-gating as the real stick. +- **Open-core line will be re-litigated forever.** Every new feature → + "is this core or paid?" Write the *principle* down now ("org-only + governance/compliance/scale is paid; anything a solo dev needs to + fall in love is free") so it's not re-argued per PR. +- **Biggest revenue gap is unbuilt.** SSO/SAML/SCIM and a real + org/workspace RBAC model are the classic enterprise wedge and they + don't exist. The split is partly an argument to *build* these as the + first paid features, not just to fence existing ones. +- **CI must prove the OSS build.** Add a pipeline that builds & tests + with the enterprise submodule **absent**. Without it, the Community + edition will silently break and you won't notice until a community + user files an issue. +- **Don't split prematurely.** If there's no enterprise customer yet, + the cheapest correct move is: (a) fix the license of record, (b) add + the `EntitlementService` + conditional registration seam in the + *public* repo (no features moved yet), (c) move code into the + submodule only when a deal needs it. The seam is cheap and reversible; + a repo split is neither. + +--- + +## 8. Stress test — does the approach survive other open issues? + +The approach was pressure-tested against a representative spread of open +feature issues, chosen to hit different *shapes* (not to confirm it). +Legend: ✅ holds cleanly · ⚠️ strains, needs a refinement · ❌ breaks an +assumption. + +> Note: the team already filed **#847 — "Spike: enterprise edition +> architecture (SSO/SCIM/SIEM, private module)"**. It independently +> arrives at private-module + the SSO/SCIM/SIEM wedge, matching this +> doc — useful corroboration, and the natural home for this research. + +| # | Feature | Shape | Gate point / where code lands | Verdict | +|---|---|---|---|---| +| **#847** | SSO/SAML, SCIM, SIEM export | The compliance wedge | SCIM = clean submodule router; SIEM = log-tap consumer (clean). **SSO is *not* clean** — it rewires token issuance in `auth.py`/`dependencies.py` | ⚠️ | +| **#846** | per-agent MCP exposure flag | New **MCP-server** tool surface | Gate must live in the TypeScript MCP server — a separate process that cannot import the Python `EntitlementService` | ❌ | +| **#772** | execution-log retention/pruning | Cross-cutting like #834 | Sweep + schema = core; the configurable/long window = enterprise knob via the resolver clamp + license `caps` | ✅ | +| **#868** | per-schedule execution analytics | Capture + aggregation | Capture must be core (history can't be backfilled); dashboards + long-window queries = enterprise surface | ✅ | +| **#848** | MCP inline email auth | Auth-path change, adoption | Core, **not gateable** — an auth/security primitive *and* a funnel feature; paywalling auth is dangerous | ⚠️ | +| **#866** | SITE-002 public agent page | Pure funnel | Keep free (§5 principle). SITE-001 was reverted (#867) → re-architected anyway; decide tier deliberately now | ✅ | +| **#736 / #738** | A2A outbound + Trinity↔Trinity federation | Multi-surface + interop standard | Federation governance = enterprise, but the gate spans backend *and* MCP tool; gating an interop standard kills its network effect | ⚠️ | +| **#851 / #717** | CI pipeline / settings-tab refactor | Not product features | Must sit entirely *outside* the entitlement model | ✅ (as exclusion) | + +### 8.1 What the stress test forced into the design + +**R1 — The `EntitlementService` needs a second form factor (the one +outright break, from #846).** §3 assumed one Python service with three +consumers. The MCP server is a separate TypeScript process and *cannot +import it*. Every feature gated at the MCP-tool layer (#846, half of +A2A #736, future MCP-exposed agents) needs the MCP server to read +entitlements over the wire — a small `GET /api/internal/entitlements` +(or an extension of the existing `/feature-flags`) fetched at MCP +startup and refreshed periodically. **Without this, an entire class of +features cannot be gated.** This must be built in Phase 0 alongside the +service itself, not retrofitted. + +**R2 — Add an explicit "not gateable" category.** #851 (CI), #717 +(refactor), #848 (auth path), and #758 (vendor telemetry) must be +*named* as non-gates. The inventory pass already over-classified Slack +as enterprise — the same failure mode. Hard rule: **never gate auth, +security, schema/data-capture, or build/CI.** Anything in this bucket is +out of scope for the license entirely. + +**R3 — "Capture is always core" is a sequencing rule, not just a code +rule** (confirmed by #834, #772, #868). Corollary that affects the +*roadmap*: for any audit/analytics/retention feature, the data capture +must ship in OSS at least one release *before* its paid surface — +otherwise Enterprise has zero history on day one and the feature looks +broken. Extraction order is therefore constrained, not free. + +**R4 — SSO is #834-shaped, not audit-log-shaped.** #847 implicitly +treats SSO as a clean private-module drop-in. It is not — it changes +core token issuance. The genuinely clean leaves in #847 are **SCIM** (a +provisioning router) and **SIEM export** (a log consumer). Extract those +first; treat SSO as the harder "core auth seam + enterprise provider +plugin" variant (the #834 pattern), scheduled accordingly. + +**R5 — Do not gate interop standards.** A2A's value *is* the network +effect; fencing outbound A2A behind Enterprise removes the reason to +adopt it. The enterprise surface is *federation governance* (managing a +fleet of Trinities), not the protocol. This sharpens the §5 funnel +principle: protocols and integrations that drive adoption stay free even +when their org-scale management is paid. + +### 8.2 The refined model — four patterns, not two + +§3 described two patterns; the stress test shows there are four. Every +gateable feature is exactly one of these: + +1. **Clean leaf** → submodule router + one `requires_entitlement` + dependency line. *Examples: audit log, SCIM, SIEM export, the #834 + recovery API.* +2. **Core primitive + enterprise knob** → the mechanism and schema stay + in core; a parameter is clamped by the license caps via the settings + resolver. *Examples: #834 retention, #772 log retention, #868 + analytics window.* +3. **Multi-surface gate with an MCP edge** → needs R1's entitlement + endpoint; the gate is replicated into the MCP process. *Examples: + #846, A2A tools.* +4. **Not gateable** → auth, security, schema/capture, CI, refactors, + vendor telemetry. Explicitly excluded from the model. *Examples: + #848, #851, #717, #758.* + +**Verdict:** the open-core + `EntitlementService` direction survives the +stress test. The corrections are additive, not structural: build the +MCP entitlement edge in Phase 0 (R1), publish the non-gate list (R2), +and respect capture-before-surface sequencing (R3). The doc's §3/§4 +remain correct for patterns 1–2; patterns 3–4 are the additions this +section introduces. + +--- + +## 9. Concrete next steps (if pursuing) + +1. **Decision (owners, not engineering):** OSS license choice; + per-instance vs per-seat; which tier each ambiguous feature lands in. +2. **Phase 0 — seam, no split (public repo, ~1 sprint):** + `EntitlementService` (stub: everything entitled), `requires_entitlement` + dependency, `/feature-flags` extension, conditional + `register_enterprise()` in `main.py` with the `ModuleNotFoundError` + fallback, the CI "build without submodule" job, **and the R1 MCP + entitlement edge** (`GET /api/internal/entitlements` + MCP-server + poll). R1 is Phase 0, not a later add — pattern-3 features can't be + gated without it. Publish the **R2 non-gate list** as a written rule + in the same phase. +3. **Phase 1 — licensing:** Ed25519 token format, verify path, signing + CLI, admin License UI, grace/clock-tamper handling. +4. **Phase 2 — extract the clean leaves:** create private + `trinity-enterprise` submodule; move *audit log* first (cleanest + boundary, highest enterprise value, self-contained: + `routers/audit_log.py` + `services/platform_audit_service.py` + its + tables), then **SCIM and SIEM export** (also clean leaves per R4). + Validate the whole machine end-to-end on audit log before moving the + rest. Respect R3: any feature whose value is captured history must + already be capturing in OSS. +5. **Phase 3 — the #834 pattern:** prove "core-primitive + enterprise-knob" + end-to-end (recovery API + license-capped retention), since most + features (incl. SSO) take this shape, not the clean-leaf shape. +6. **Phase 4 — build SSO/SAML** as the first net-new paid feature, using + the Phase 3 pattern (core auth seam + enterprise provider plugin — + it is *not* a clean drop-in; see R4). + +--- + +*End of research. No code moved, no license changed — this documents +options and a recommendation only.* diff --git a/src/backend/dependencies.py b/src/backend/dependencies.py index 65b7bff5a..9dd5f0f4d 100644 --- a/src/backend/dependencies.py +++ b/src/backend/dependencies.py @@ -198,6 +198,48 @@ def _require_role(current_user: User = Depends(get_current_user)) -> User: return _require_role +def requires_entitlement(feature_id: str): + """Dependency factory: require an entitlement for the named enterprise feature. + + Issue #847 — Phase 0 seam. Consults the ``EntitlementService`` (stub + today, license-checked in a later phase) to decide whether the + request is allowed to use a paid feature. + + Usage: + from dependencies import requires_entitlement + + @router.get("/some-enterprise-endpoint") + async def handler(_: None = Depends(requires_entitlement("sso"))): + ... + + The dependency returns nothing on success. On failure raises HTTP + 403 with detail naming the missing entitlement so the UI can surface + a "license required" message and the operator can correlate with + `system_settings`. + + The stub implementation in ``services.entitlement_service`` returns + True for every feature_id in the OSS build — the seam exists so that + enterprise routers can be wired today without conditionally adding + a guard later. When a license check lands, all gated endpoints get + real enforcement with zero diff at the call site. + """ + def _requires_entitlement(): + # Lazy import: keeps `dependencies.py` importable even when the + # entitlement module isn't loaded yet (e.g. during partial + # module init in tests). + from services.entitlement_service import entitlement_service + if not entitlement_service.is_entitled(feature_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=( + f"Enterprise feature '{feature_id}' is not licensed for " + "this instance. Contact your administrator." + ), + ) + return None + return _requires_entitlement + + # ============================================================================ # Agent Access Control Dependencies # ============================================================================ diff --git a/src/backend/enterprise b/src/backend/enterprise new file mode 160000 index 000000000..f3b5534fd --- /dev/null +++ b/src/backend/enterprise @@ -0,0 +1 @@ +Subproject commit f3b5534fd05ad290aa3749704ae0dbdb36973616 diff --git a/src/backend/main.py b/src/backend/main.py index efc4583cf..d11a3365e 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -853,6 +853,30 @@ async def add_security_headers(request: Request, call_next): app.include_router(ws_tickets_router) # WebSocket auth tickets (#550) +# #847 Phase 0 — Enterprise modules (closed-source companion submodule +# at `src/backend/enterprise/`, repo `Abilityai/trinity-enterprise`). +# The submodule is OPTIONAL: customers running the public repo without +# enterprise access clone without it, and the ImportError below silently +# no-ops. When mounted, `register_enterprise(app)` installs the SSO / +# SCIM / SIEM routers under `/api/enterprise/*`. The function is +# idempotent (guards on `app.state.enterprise_registered`). Entitlement +# gating happens per-endpoint via `requires_entitlement(feature_id)` +# from `dependencies.py` — endpoints are mounted unconditionally and +# the gate decides whether to serve them. This keeps the wiring +# deterministic regardless of license state. +try: + from enterprise import register_enterprise # type: ignore[import-not-found] + register_enterprise(app) + _logger = logging.getLogger(__name__) + _logger.info("Trinity Enterprise modules registered") +except ImportError: + _logger = logging.getLogger(__name__) + _logger.info( + "Trinity Enterprise submodule not present — OSS-only build " + "(this is normal; enterprise modules are an optional private submodule)" + ) + + # WebSocket endpoint @app.websocket("/ws") async def websocket_endpoint( diff --git a/src/backend/routers/settings.py b/src/backend/routers/settings.py index 1855bdf61..8bd1d01a4 100644 --- a/src/backend/routers/settings.py +++ b/src/backend/routers/settings.py @@ -126,12 +126,18 @@ async def get_public_feature_flags( still keep them out of the unauthenticated surface. """ from config import GEMINI_API_KEY, VOICE_ENABLED + from services.entitlement_service import entitlement_service voice_available = VOICE_ENABLED and bool(GEMINI_API_KEY) return { "session_tab_enabled": settings_service.is_session_tab_enabled(), "voice_available": voice_available, "workspace_available": voice_available and settings_service.is_workspace_enabled(), "platform_default_model": settings_service.get_platform_default_model(), + # #847 Phase 0 — enterprise entitlements. Empty list means OSS + # build (or TRINITY_OSS_ONLY=1). UI uses this to hide + # enterprise-only tabs cleanly without server-side conditional + # rendering. Mirrors the deny-list pattern of the other flags. + "enterprise_features": entitlement_service.list_entitled_features(), } diff --git a/src/backend/services/entitlement_service.py b/src/backend/services/entitlement_service.py new file mode 100644 index 000000000..4cc81b877 --- /dev/null +++ b/src/backend/services/entitlement_service.py @@ -0,0 +1,105 @@ +"""Entitlement Service — gates enterprise features at runtime (#847 seam). + +Phase 0 stub. Answers ``is_entitled(feature_id)`` for the +``requires_entitlement`` FastAPI dependency in ``dependencies.py``. + +This stub returns True for every feature_id. The seam exists so +enterprise routers can be wired into the app today (via the private +submodule at ``src/backend/enterprise/``) without each call site needing +a license-check conditional bolted on later. When the real license +mechanism lands (Phase 1 — Ed25519-signed token + offline verify), this +class gets the verification logic and every gated endpoint picks it up +with zero diff at the call site. + +Why a class and not a module function: + +* Phase 1 needs cached state (license blob, parsed claims, expiry, + grace window). A class is the natural home. +* Tests can swap the singleton via ``_set_for_testing(service)`` without + touching ``sys.modules``. + +Public API +---------- +* ``entitlement_service`` — module-level singleton, imported by the + ``requires_entitlement`` dependency +* ``EntitlementService.is_entitled(feature_id: str) -> bool`` +* ``EntitlementService.list_entitled_features() -> list[str]`` + +Read by: +* ``dependencies.requires_entitlement(...)`` — per-request gate +* ``routers/settings.get_feature_flags`` — UI hide/show signal +* (future) MCP server via ``GET /api/internal/entitlements`` +""" +from __future__ import annotations + +import logging +import os +from typing import Optional + +logger = logging.getLogger(__name__) + + +class EntitlementService: + """In-process entitlement check. + + Phase 0 (this PR): all features entitled. The ``oss_only`` mode + (env ``TRINITY_OSS_ONLY=1``) flips every check to False, useful for + operators who want to lock down a Trinity instance to the OSS + surface even when the enterprise submodule is mounted (e.g. for + a clean compliance posture in a free-tier deployment). + """ + + def __init__(self) -> None: + # When TRINITY_OSS_ONLY=1, deny every feature. Use cases: + # operators running the public repo without enterprise, who + # still want UI affordances to hide enterprise tabs; CI builds + # that exercise the deny path. + self._oss_only = os.getenv("TRINITY_OSS_ONLY", "0").lower() in {"1", "true", "yes"} + if self._oss_only: + logger.info( + "[EntitlementService] TRINITY_OSS_ONLY=1 — all enterprise " + "features will report as not-entitled" + ) + + def is_entitled(self, feature_id: str) -> bool: + """Return True if the named feature is licensed for this instance. + + Phase 0: True for any feature_id unless OSS-only mode is set. + Phase 1: cross-checks the license claim set. + """ + if self._oss_only: + return False + # Phase 1 will replace this with a real license check. + return True + + def list_entitled_features(self) -> list[str]: + """Return the set of feature IDs this instance is entitled to. + + Used by ``GET /api/settings/feature-flags`` to drive UI tabs. + Phase 0: returns the well-known feature IDs when entitled. + """ + if self._oss_only: + return [] + # Phase 0: the set known to the seam. Real implementations + # would derive from license claims. + return ["sso", "scim", "siem"] + + +# Module-level singleton — what `dependencies.requires_entitlement` calls. +entitlement_service = EntitlementService() + + +def _set_for_testing(service: Optional[EntitlementService]) -> None: + """Replace the singleton. Test-only. + + Pass an instance with custom behaviour (e.g. ``is_entitled`` mocked + to return False) to exercise the deny path without monkeypatching + ``sys.modules`` or environment variables. + + Pass None to restore the default singleton. + """ + global entitlement_service + if service is None: + entitlement_service = EntitlementService() + else: + entitlement_service = service diff --git a/tests/unit/test_847_entitlement_seam.py b/tests/unit/test_847_entitlement_seam.py new file mode 100644 index 000000000..7129d5109 --- /dev/null +++ b/tests/unit/test_847_entitlement_seam.py @@ -0,0 +1,197 @@ +"""Tests for #847 Phase 0 enterprise seam. + +Verifies: + +1. ``EntitlementService.is_entitled`` returns True for every feature + in the default (stub) configuration. +2. ``TRINITY_OSS_ONLY=1`` flips every check to False — the + ``oss_only`` deny path. +3. ``requires_entitlement(feature_id)`` raises HTTP 403 when the + service denies and returns None on allow. +4. ``list_entitled_features()`` reports the known feature IDs in + the OSS build. +5. ``_set_for_testing`` cleanly swaps the singleton. + +The conditional ``register_enterprise`` import in ``main.py`` is +covered by an integration test (live container) rather than a unit +test — the unit path can't faithfully simulate the submodule's +absence. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + + +_BACKEND = Path(__file__).resolve().parent.parent.parent / "src" / "backend" +_BACKEND_STR = str(_BACKEND) +while _BACKEND_STR in sys.path: + sys.path.remove(_BACKEND_STR) +sys.path.insert(0, _BACKEND_STR) + + +# ----------------------------------------------------------------------------- +# EntitlementService default behaviour +# ----------------------------------------------------------------------------- + + +def test_default_is_entitled_returns_true(monkeypatch): + """Stub mode (no TRINITY_OSS_ONLY): every feature_id is entitled.""" + monkeypatch.delenv("TRINITY_OSS_ONLY", raising=False) + from services.entitlement_service import EntitlementService + + svc = EntitlementService() + assert svc.is_entitled("sso") is True + assert svc.is_entitled("scim") is True + assert svc.is_entitled("siem") is True + # Unknown features also return True in stub mode — the seam doesn't + # pretend to know the catalogue yet (that's Phase 1 license claims). + assert svc.is_entitled("not-a-real-feature") is True + + +def test_default_list_entitled_features(monkeypatch): + """Stub mode reports the known enterprise feature catalogue.""" + monkeypatch.delenv("TRINITY_OSS_ONLY", raising=False) + from services.entitlement_service import EntitlementService + + svc = EntitlementService() + features = svc.list_entitled_features() + assert "sso" in features + assert "scim" in features + assert "siem" in features + + +# ----------------------------------------------------------------------------- +# TRINITY_OSS_ONLY=1 deny path +# ----------------------------------------------------------------------------- + + +@pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes"]) +def test_oss_only_denies_every_feature(monkeypatch, value): + """Any truthy spelling of TRINITY_OSS_ONLY flips all checks False.""" + monkeypatch.setenv("TRINITY_OSS_ONLY", value) + # Reimport so the constructor re-reads the env var. + if "services.entitlement_service" in sys.modules: + del sys.modules["services.entitlement_service"] + from services.entitlement_service import EntitlementService + + svc = EntitlementService() + assert svc.is_entitled("sso") is False + assert svc.is_entitled("scim") is False + assert svc.list_entitled_features() == [] + + +@pytest.mark.parametrize("value", ["0", "false", "no", ""]) +def test_oss_only_falsy_keeps_entitlements(monkeypatch, value): + """Falsy spellings (and empty string) leave the default stub + behaviour intact.""" + monkeypatch.setenv("TRINITY_OSS_ONLY", value) + if "services.entitlement_service" in sys.modules: + del sys.modules["services.entitlement_service"] + from services.entitlement_service import EntitlementService + + svc = EntitlementService() + assert svc.is_entitled("sso") is True + + +# ----------------------------------------------------------------------------- +# `requires_entitlement` dependency factory +# ----------------------------------------------------------------------------- + + +def _import_requires_entitlement_or_skip(): + """Import ``dependencies.requires_entitlement`` or skip if backend + venv isn't available locally (e.g. ``passlib`` missing in a stub + dev environment). CI installs the full backend deps so this + skip never fires there.""" + try: + if "dependencies" in sys.modules: + del sys.modules["dependencies"] + from dependencies import requires_entitlement + return requires_entitlement + except ImportError as e: + pytest.skip(f"backend venv required (no `dependencies` import: {e})") + + +def test_requires_entitlement_allows_when_entitled(monkeypatch): + """The Depends() callable returns None on allow.""" + monkeypatch.delenv("TRINITY_OSS_ONLY", raising=False) + if "services.entitlement_service" in sys.modules: + del sys.modules["services.entitlement_service"] + requires_entitlement = _import_requires_entitlement_or_skip() + + inner = requires_entitlement("sso") + assert inner() is None + + +def test_requires_entitlement_raises_403_when_denied(monkeypatch): + """Deny path raises HTTPException(403) with the feature_id in detail.""" + from fastapi import HTTPException + + monkeypatch.setenv("TRINITY_OSS_ONLY", "1") + if "services.entitlement_service" in sys.modules: + del sys.modules["services.entitlement_service"] + requires_entitlement = _import_requires_entitlement_or_skip() + + inner = requires_entitlement("sso") + with pytest.raises(HTTPException) as exc: + inner() + assert exc.value.status_code == 403 + assert "sso" in exc.value.detail + + +# ----------------------------------------------------------------------------- +# _set_for_testing +# ----------------------------------------------------------------------------- + + +def test_set_for_testing_swaps_singleton(monkeypatch): + """Replacing the singleton lets tests force specific behaviour + without monkeypatching the env or sys.modules.""" + monkeypatch.delenv("TRINITY_OSS_ONLY", raising=False) + if "services.entitlement_service" in sys.modules: + del sys.modules["services.entitlement_service"] + import services.entitlement_service as ent_mod + + class _StubFalse: + def is_entitled(self, _feature_id): + return False + + def list_entitled_features(self): + return [] + + ent_mod._set_for_testing(_StubFalse()) + try: + assert ent_mod.entitlement_service.is_entitled("sso") is False + assert ent_mod.entitlement_service.list_entitled_features() == [] + finally: + ent_mod._set_for_testing(None) # restore default + # Restored — stub-default singleton behaviour returns True again + assert ent_mod.entitlement_service.is_entitled("sso") is True + + +# ----------------------------------------------------------------------------- +# Static check: main.py must import enterprise conditionally +# ----------------------------------------------------------------------------- + + +def test_main_py_uses_conditional_enterprise_import(): + """The enterprise loader must be in a try/except ImportError so + OSS-only builds (no submodule) start cleanly.""" + src = (_BACKEND / "main.py").read_text(encoding="utf-8") + # Find the import line + idx = src.find("from enterprise import register_enterprise") + assert idx != -1, "main.py must import register_enterprise from enterprise" + # The preceding ~150 chars should contain `try:` + window = src[max(0, idx - 200) : idx] + assert "try:" in window, ( + "enterprise import must be inside a try/except ImportError block " + "so OSS-only builds (no submodule) boot cleanly" + ) + # And the trailing block should catch ImportError + tail = src[idx : idx + 400] + assert "except ImportError" in tail, ( + "enterprise import must be guarded by `except ImportError`" + ) From 6b4402b8a8def022b08056ba8aa927e3989a49a7 Mon Sep 17 00:00:00 2001 From: Oleksii Dolhov Date: Fri, 22 May 2026 12:31:37 +0300 Subject: [PATCH 02/17] feat(enterprise): dual-mount submodule + frontend SSO view (#847 Phase 0.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the frontend half of the enterprise seam. The private repo `Abilityai/trinity-enterprise` was restructured into `backend/` and `frontend/` subdirs, and the public repo now mounts it as TWO submodules at different paths — same URL, different mount points — so each consumer reads only its own subdir: src/backend/enterprise/ → backend/ consumed by Python (`main.py`) src/frontend/src/enterprise/ → frontend/ consumed by Vite (`main.js`) One private codebase to version; two clean import surfaces. Disk waste is ~2× the repo size (the same code cloned twice) which is far cheaper than two private repos drifting out of sync. Changes: Backend (import path bump): * `src/backend/main.py` — `from enterprise.backend import register_enterprise` (was `from enterprise import ...`). * `tests/unit/test_847_entitlement_seam.py` — static-check asserts the new import path. Frontend (new): * `src/frontend/src/main.js` — conditional `import.meta.glob('./enterprise/frontend/index.js', { eager: false })`. Empty in OSS-only builds; calls `mod.registerEnterprise(router, app)` when present. Logs which mode it's in. * `src/frontend/src/stores/enterprise.js` — new Pinia store. Loads `/api/settings/feature-flags` after auth, caches `enterprise_features: list[str]`. Getters: `isEntitled(featureId)`, `hasAnyEnterprise`. Test seam `_setFeaturesForTest`. * `src/frontend/src/components/NavBar.vue` — new `Enterprise` link `v-if="enterpriseStore.isEntitled('sso')"` with `PRO` badge. Hidden in OSS-only mode and when `TRINITY_OSS_ONLY=1`. Submodule: * `.gitmodules` — second entry at `src/frontend/src/enterprise/` for the same private repo URL. CI: * `.github/workflows/build-without-submodule.yml` — asserts BOTH mount points are empty (`backend/__init__.py` AND `frontend/index.js`) and that the OSS-only log line + 404 + empty `enterprise_features` invariants hold. Docs: * `docs/planning/ENTERPRISE_ARCHITECTURE.md` — directory tree + why one repo + dual-mount config + frontend seam description. * `docs/dev/ENTERPRISE_LOCAL_DEV.md` — three-submodule table, "working on the enterprise repo" steps for dual-mount sync. * `docs/memory/requirements.md` §34.1 — frontend integration in Key Features + private-repo layout. Private repo content (Abilityai/trinity-enterprise) restructured in a sibling commit (`refactor: split backend/ and frontend/ subdirs`): * `backend/__init__.py` + `backend/sso/` — was at repo root. * `frontend/index.js` — `registerEnterprise(router, app)`. Adds the `/enterprise/sso` route pointing at `EnterpriseSSO.vue`. * `frontend/views/EnterpriseSSO.vue` — Vue component. Fetches `/api/enterprise/sso/providers` via the public repo's shared `api.js`. Empty-state UI with the issue link. Live verification (local instance): * `/api/enterprise/sso/providers` → `[]` (backend still works after import path change). * Vite serves `/src/enterprise/frontend/index.js` (200) and `/src/enterprise/frontend/views/EnterpriseSSO.vue` (200) with the `api.js` import resolved to `/src/api.js`. * `import.meta.glob` returns a populated object → enterprise module loaded → `[enterprise] frontend module loaded` console log. Tests: 12 passed + 2 skipped (no-passlib local). No new test surface for the frontend store/component — playwright e2e would catch regressions but is out of scope for this Phase 0.5. Related to #847 Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build-without-submodule.yml | 21 +++-- .gitmodules | 3 + docs/dev/ENTERPRISE_LOCAL_DEV.md | 36 +++++--- docs/memory/requirements.md | 37 +++++++-- docs/planning/ENTERPRISE_ARCHITECTURE.md | 82 ++++++++++++++++--- src/backend/enterprise | 2 +- src/backend/main.py | 8 +- src/frontend/src/components/NavBar.vue | 24 ++++++ src/frontend/src/enterprise | 1 + src/frontend/src/main.js | 30 +++++++ src/frontend/src/stores/enterprise.js | 65 +++++++++++++++ tests/unit/test_847_entitlement_seam.py | 9 +- 12 files changed, 275 insertions(+), 43 deletions(-) create mode 160000 src/frontend/src/enterprise create mode 100644 src/frontend/src/stores/enterprise.js diff --git a/.github/workflows/build-without-submodule.yml b/.github/workflows/build-without-submodule.yml index 2ad03105c..80fb57ed3 100644 --- a/.github/workflows/build-without-submodule.yml +++ b/.github/workflows/build-without-submodule.yml @@ -49,15 +49,20 @@ jobs: fetch-depth: 1 persist-credentials: false - - name: Confirm enterprise submodule is absent + - name: Confirm enterprise submodules are absent (both mounts) run: | - # Submodule mount point exists in tree but no checked-out content - # when --submodules=false. - if [ -f src/backend/enterprise/__init__.py ]; then - echo "::error::src/backend/enterprise/__init__.py present — checkout pulled the submodule. The job must run without it." - exit 1 - fi - echo "OK: enterprise submodule absent as expected" + # Two mount points (#847 Phase 0.5 — dual-mount). Both must + # be empty when --submodules=false. The expected layout + # under each mount when populated: + # src/backend/enterprise/backend/__init__.py (Python) + # src/frontend/src/enterprise/frontend/index.js (Vite) + for path in src/backend/enterprise/backend/__init__.py src/frontend/src/enterprise/frontend/index.js; do + if [ -f "$path" ]; then + echo "::error::$path present — checkout pulled the submodule. The job must run without it." + exit 1 + fi + done + echo "OK: enterprise submodules absent as expected (both mount points empty)" - name: Generate ephemeral secrets for boot run: | diff --git a/.gitmodules b/.gitmodules index 6398a43c6..92c294d3d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -6,3 +6,6 @@ [submodule "src/backend/enterprise"] path = src/backend/enterprise url = git@github.com:Abilityai/trinity-enterprise.git +[submodule "src/frontend/src/enterprise"] + path = src/frontend/src/enterprise + url = git@github.com:Abilityai/trinity-enterprise.git diff --git a/docs/dev/ENTERPRISE_LOCAL_DEV.md b/docs/dev/ENTERPRISE_LOCAL_DEV.md index b99675a15..52674a4a9 100644 --- a/docs/dev/ENTERPRISE_LOCAL_DEV.md +++ b/docs/dev/ENTERPRISE_LOCAL_DEV.md @@ -38,12 +38,19 @@ submodules in: git submodule update --init --recursive ``` -`.gitmodules` mounts two private submodules: +`.gitmodules` mounts three private submodules (the enterprise repo is +**dual-mounted** — same URL, two paths, so backend and frontend each +get a clean import surface): -| Submodule | Path | Purpose | -|---|---|---| -| `.claude` | `.claude/` | Dev-methodology skills (`/sprint`, `/cso`, `/autoplan`, etc.) | -| `src/backend/enterprise` | `src/backend/enterprise/` | Enterprise compliance modules (SSO, SCIM, SIEM) | +| Submodule | Path | Consumed by | Subdir read | +|---|---|---|---| +| `.claude` | `.claude/` | Claude Code skills (`/sprint`, `/cso`, …) | (all) | +| `src/backend/enterprise` | `src/backend/enterprise/` | Python (`main.py`) | `backend/` | +| `src/frontend/src/enterprise` | `src/frontend/src/enterprise/` | Vite (`main.js`) | `frontend/` | + +The two enterprise mounts clone the same repo, so disk usage is ~2× +the repo size. In exchange you get a single enterprise codebase to +version, and each consumer imports only the subdir it needs. Both use SSH (`git@github.com:...`). If you only have HTTPS auth configured, add an SSH override: @@ -93,20 +100,29 @@ import worked, and the entitlement seam reports all features enabled ## Working on the enterprise repo +The repo is dual-mounted. Pick **one** of the two mounts to make +changes in (typically `src/backend/enterprise/` since that's where +you most often start) — `git push` from there updates the upstream, +then the other mount can `git pull` to sync. + ```bash cd src/backend/enterprise git checkout main # submodules default to detached HEAD -# … make changes … +# … make changes (in backend/ or frontend/ subdir) … git commit -m "feat(sso): ..." git push origin main + +# Sync the other mount +cd ../../../src/frontend/src/enterprise +git checkout main +git pull origin main ``` -Then, back in the public repo, **bump the submodule pointer** so other -developers see your changes: +Then, back in the public repo root, **bump both submodule pointers**: ```bash -cd ../../../ # back to trinity root -git add src/backend/enterprise +cd +git add src/backend/enterprise src/frontend/src/enterprise git commit -m "chore: bump enterprise submodule" git push ``` diff --git a/docs/memory/requirements.md b/docs/memory/requirements.md index 75f24032e..31112d82b 100644 --- a/docs/memory/requirements.md +++ b/docs/memory/requirements.md @@ -2391,8 +2391,23 @@ Standalone mobile-friendly admin page for managing agents on the go. Designed as `enterprise_features: list[str]` — empty in OSS mode, populated when entitled. UI uses this to hide enterprise-only tabs cleanly without server-side conditional rendering. - - `.gitmodules` entry for `src/backend/enterprise` via SSH - (`git@github.com:Abilityai/trinity-enterprise.git`). + - `.gitmodules` — **dual-mount** of `Abilityai/trinity-enterprise` + at `src/backend/enterprise/` (Python consumes `backend/` subdir) + and `src/frontend/src/enterprise/` (Vite consumes `frontend/` + subdir). Same URL, two paths — keeps the enterprise codebase in + one repo while exposing clean import surfaces for each layer. + - Frontend integration in `src/frontend/src/main.js` — + `import.meta.glob('./enterprise/frontend/index.js', { eager: false })`. + Empty glob → no-op (OSS-only build). When present, the module's + `registerEnterprise(router, app)` runs after Pinia + Router setup + and adds routes via `router.addRoute`. + - Pinia store `src/frontend/src/stores/enterprise.js` — caches + `enterprise_features: list[str]` from `/api/settings/feature-flags`. + Exposes `isEntitled(featureId)` + `hasAnyEnterprise` getters. + - `NavBar.vue` — new `Enterprise` link + `v-if="enterpriseStore.isEntitled('sso')"` with a `PRO` badge. + Hidden in OSS-only mode (empty list) and when operator forces + `TRINITY_OSS_ONLY=1`. - `docker-compose.yml` env pass-through for `TRINITY_OSS_ONLY`. - CI workflow `.github/workflows/build-without-submodule.yml` — boots the backend with the submodule absent and asserts: @@ -2401,15 +2416,21 @@ Standalone mobile-friendly admin page for managing agents on the go. Designed as returns 404, and the OSS-only log line is emitted. Catches regressions where the conditional import accidentally becomes hard-required. -- **Private repo (Abilityai/trinity-enterprise)**: - - `__init__.py` — `register_enterprise(app)` single entry point - - `sso/router.py` — `/api/enterprise/sso/{providers,login/{id}}` +- **Private repo (Abilityai/trinity-enterprise)** — dual-mounted: + - `backend/__init__.py` — `register_enterprise(app)` single FastAPI + entry point. + - `backend/sso/router.py` — `/api/enterprise/sso/{providers,login/{id}}` stubs gated by `requires_entitlement("sso")`. `GET /providers` returns the in-process registry (empty by default); `POST /login/{id}` returns 501 "PoC stub" or 404 for unknown id. - - `sso/providers.py` — `SSOProvider` ABC (provider_id, - display_name, protocol, begin_login) + `StubProvider` for the - PoC. + - `backend/sso/providers.py` — `SSOProvider` ABC (provider_id, + display_name, protocol, begin_login) + `StubProvider`. + - `frontend/index.js` — `registerEnterprise(router, app)` single + Vue Router entry point. Idempotent (module-local flag). + - `frontend/views/EnterpriseSSO.vue` — Vue view at + `/enterprise/sso`. Fetches `/api/enterprise/sso/providers`, + renders empty-state message in the PoC; provider rows with + disabled "Login (stub)" buttons when the list isn't empty. - `pyproject.toml`, `LICENSE` (proprietary), `README.md`. - **Out of scope (separate follow-ups)**: - Phase 1: Ed25519-signed license token, verify path, admin diff --git a/docs/planning/ENTERPRISE_ARCHITECTURE.md b/docs/planning/ENTERPRISE_ARCHITECTURE.md index f29c779b8..eba89b5ca 100644 --- a/docs/planning/ENTERPRISE_ARCHITECTURE.md +++ b/docs/planning/ENTERPRISE_ARCHITECTURE.md @@ -34,22 +34,56 @@ abilityai/trinity (public, MIT/Apache after decision #6) │ ├── dependencies.py requires_entitlement(feature_id) — Phase 0 seam │ ├── services/ │ │ └── entitlement_service.py EntitlementService (Phase 0 stub; Phase 1 license) -│ └── enterprise/ ← submodule mount point -│ └── (populated by submodule init — see below) +│ └── enterprise/ ← submodule mount #1 (Python imports backend/) +│ └── (populated by submodule init — same repo, both subdirs) +├── src/frontend/src/ +│ ├── main.js conditional `import.meta.glob` of enterprise/frontend/ +│ ├── stores/enterprise.js enterprise feature-flags store +│ └── enterprise/ ← submodule mount #2 (Vite imports frontend/) +│ └── (populated by submodule init — same repo, both subdirs) ├── docs/planning/ │ ├── ENTERPRISE_ARCHITECTURE.md this file │ └── OSS_ENTERPRISE_SPLIT_RESEARCH.md long-form research └── docs/dev/ └── ENTERPRISE_LOCAL_DEV.md 15-min onboarding guide -Abilityai/trinity-enterprise (private, proprietary) -├── __init__.py register_enterprise(app) entry point -├── sso/ #847 PoC (router + provider ABC + stubs) -├── scim/ planned (#???) -├── siem/ planned (#???) +Abilityai/trinity-enterprise (private, proprietary, dual-mounted) +├── backend/ +│ ├── __init__.py register_enterprise(app) entry point +│ └── sso/ #847 PoC (router + provider ABC + stubs) +├── frontend/ +│ ├── index.js registerEnterprise(router, app) entry point +│ └── views/ +│ └── EnterpriseSSO.vue #847 PoC (Vue component) └── LICENSE commercial / proprietary ``` +### Why two mount points of the same repo (not two repos) + +Symmetric to the backend/frontend split in the public repo — keeping +the enterprise code in **one** private repo means a single version +bump touches both backend and frontend together. The mild disk +duplication (~1 MB cloned twice) is far cheaper than two repos +drifting out of sync. + +`.gitmodules` declares both mounts at the same URL but different +paths: + +```ini +[submodule "src/backend/enterprise"] + path = src/backend/enterprise + url = git@github.com:Abilityai/trinity-enterprise.git +[submodule "src/frontend/src/enterprise"] + path = src/frontend/src/enterprise + url = git@github.com:Abilityai/trinity-enterprise.git +``` + +Each consumer reads only its own subdir: +- Python `from enterprise.backend import register_enterprise` resolves + to `src/backend/enterprise/backend/__init__.py`. +- Vite `import.meta.glob('./enterprise/frontend/index.js')` resolves to + `src/frontend/src/enterprise/frontend/index.js`. + ## Why a submodule, not a Python package Three options compared in the research: @@ -65,7 +99,7 @@ enterprise code in the same checkout as Trinity — no `pip install -e` dance, no symlink hacks. Compare CI cost: the `build-without-submodule` job below proves the conditional import works. -## The seam (what landed in this PR — #847 Phase 0) +## The seam (what landed in this PR — #847 Phase 0 + 0.5) **`src/backend/services/entitlement_service.py`** - `EntitlementService.is_entitled(feature_id) -> bool` — returns True @@ -89,34 +123,56 @@ job below proves the conditional import works. OSS-only (UI hides enterprise tabs). **`.gitmodules`** -- New `src/backend/enterprise` submodule entry pointing at the private repo via SSH. +- Two submodule entries (`src/backend/enterprise` + `src/frontend/src/enterprise`) + same URL pointing at the private repo via SSH. **`docker-compose.yml`** - Pass-through for `TRINITY_OSS_ONLY` env var. +**`src/frontend/src/main.js`** +- Conditional `import.meta.glob('./enterprise/frontend/index.js')`. + Empty in OSS-only builds; module's `registerEnterprise(router, app)` + runs when present. + +**`src/frontend/src/stores/enterprise.js`** (new) +- Pinia store. Loads `/api/settings/feature-flags` after auth, caches + `enterprise_features: list[str]`. Exposes `isEntitled(featureId)` and + `hasAnyEnterprise` getters. + +**`src/frontend/src/components/NavBar.vue`** +- New `Enterprise` nav link `v-if="enterpriseStore.isEntitled('sso')"`. + Hidden in OSS-only builds (empty list) and when the operator forces + `TRINITY_OSS_ONLY=1`. + ## What the private repo holds (`Abilityai/trinity-enterprise`) PoC scope (this PR): | File | Purpose | |---|---| -| `__init__.py` | `register_enterprise(app)` — single integration entry | -| `sso/router.py` | `/api/enterprise/sso/{providers,login/{id}}` — stubs, gated by `requires_entitlement("sso")` | -| `sso/providers.py` | `SSOProvider` ABC + `StubProvider` for the PoC registry | +| `backend/__init__.py` | `register_enterprise(app)` — FastAPI integration entry | +| `backend/sso/router.py` | `/api/enterprise/sso/{providers,login/{id}}` — stubs, gated by `requires_entitlement("sso")` | +| `backend/sso/providers.py` | `SSOProvider` ABC + `StubProvider` for the PoC registry | +| `frontend/index.js` | `registerEnterprise(router, app)` — Vue Router integration entry | +| `frontend/views/EnterpriseSSO.vue` | Vue component — providers list view (empty state in PoC) | | `pyproject.toml` | Metadata (no pip-install mode yet — submodule mount only) | | `LICENSE` | Proprietary | ## How CI handles "build without submodule" Workflow `.github/workflows/build-without-submodule.yml` boots the -backend image with the submodule **absent** and asserts: +backend image with both submodule mounts **absent** and asserts: 1. Container starts cleanly 2. `GET /api/settings/feature-flags` returns `enterprise_features: []` 3. `GET /api/enterprise/sso/providers` returns 404 (router not mounted) +4. OSS-only log line emitted This proves the conditional import doesn't break OSS-only deployments when the submodule URL access is revoked or the submodule is unchecked. +The frontend side is symmetric: `import.meta.glob` returns `{}` when +`src/frontend/src/enterprise/` is empty, so `main.js` silently no-ops +and no enterprise route ever registers. ## What's NOT in this PR (open follow-ups) diff --git a/src/backend/enterprise b/src/backend/enterprise index f3b5534fd..c8b46598c 160000 --- a/src/backend/enterprise +++ b/src/backend/enterprise @@ -1 +1 @@ -Subproject commit f3b5534fd05ad290aa3749704ae0dbdb36973616 +Subproject commit c8b46598c8bfccd20c72f79a8d7001445e5236e6 diff --git a/src/backend/main.py b/src/backend/main.py index d11a3365e..d3904a582 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -864,8 +864,14 @@ async def add_security_headers(request: Request, call_next): # from `dependencies.py` — endpoints are mounted unconditionally and # the gate decides whether to serve them. This keeps the wiring # deterministic regardless of license state. +# +# Import path is `enterprise.backend.register_enterprise`: the private +# repo is restructured into `backend/` and `frontend/` subdirs so the +# same repo can be dual-mounted (`src/backend/enterprise/` for Python, +# `src/frontend/src/enterprise/` for Vite). See +# `docs/planning/ENTERPRISE_ARCHITECTURE.md` for rationale. try: - from enterprise import register_enterprise # type: ignore[import-not-found] + from enterprise.backend import register_enterprise # type: ignore[import-not-found] register_enterprise(app) _logger = logging.getLogger(__name__) _logger.info("Trinity Enterprise modules registered") diff --git a/src/frontend/src/components/NavBar.vue b/src/frontend/src/components/NavBar.vue index 5ca38c251..05528f7a6 100644 --- a/src/frontend/src/components/NavBar.vue +++ b/src/frontend/src/components/NavBar.vue @@ -66,6 +66,21 @@ > Settings + + + Enterprise + PRO +
@@ -254,6 +269,7 @@ import { useAuthStore } from '../stores/auth' import { useThemeStore } from '../stores/theme' import { useNotificationsStore } from '../stores/notifications' import { useOperatorQueueStore } from '../stores/operatorQueue' +import { useEnterpriseStore } from '../stores/enterprise' import { useWebSocket } from '../utils/websocket' import { useBuildInfo } from '../composables/useBuildInfo' import axios from 'axios' @@ -263,6 +279,9 @@ const authStore = useAuthStore() const themeStore = useThemeStore() const notificationsStore = useNotificationsStore() const operatorQueueStore = useOperatorQueueStore() +// #847 Phase 0 — feature-flags load is fired on mount below; the +// `Enterprise` nav link template is `v-if="enterpriseStore.isEntitled('sso')"`. +const enterpriseStore = useEnterpriseStore() const { isConnected } = useWebSocket() // #926: cached fetch of /api/version (singleton across NavBar + Settings) @@ -332,6 +351,11 @@ onMounted(async () => { } catch (e) { console.warn('Failed to fetch user role:', e) } + + // #847 Phase 0 — load enterprise entitlements. Fires once per page + // load (the store gates on `featureFlagsLoaded`). The Enterprise nav + // link is hidden until this resolves. + enterpriseStore.loadFeatureFlags() }) onUnmounted(() => { diff --git a/src/frontend/src/enterprise b/src/frontend/src/enterprise new file mode 160000 index 000000000..c8b46598c --- /dev/null +++ b/src/frontend/src/enterprise @@ -0,0 +1 @@ +Subproject commit c8b46598c8bfccd20c72f79a8d7001445e5236e6 diff --git a/src/frontend/src/main.js b/src/frontend/src/main.js index a33935b7a..e71d39119 100644 --- a/src/frontend/src/main.js +++ b/src/frontend/src/main.js @@ -40,4 +40,34 @@ axios.interceptors.response.use( } ) +// #847 Phase 0 — load enterprise frontend module if present. The +// submodule at `src/frontend/src/enterprise/` is OPTIONAL — OSS-only +// builds clone without it and the glob below returns an empty object, +// so this block silently no-ops. When the submodule IS mounted, its +// `frontend/index.js` exports `registerEnterprise(router, app)` which +// calls `router.addRoute(...)` for each enterprise view. Routes carry +// `meta.requiresEntitlement` so the nav bar can hide them when the +// auth store reports the feature is not entitled. +// +// `eager: false` keeps the module out of the initial bundle — it's +// fetched only when the import is called. The route guard double- +// checks entitlement (defense in depth against a mounted-but-not- +// licensed scenario). +const enterpriseModules = import.meta.glob('./enterprise/frontend/index.js', { eager: false }) +const enterpriseEntry = Object.values(enterpriseModules)[0] +if (enterpriseEntry) { + enterpriseEntry().then((mod) => { + if (typeof mod.registerEnterprise === 'function') { + mod.registerEnterprise(router, app) + console.info('[enterprise] frontend module loaded') + } else { + console.warn('[enterprise] frontend/index.js did not export registerEnterprise') + } + }).catch((err) => { + console.error('[enterprise] failed to load frontend module:', err) + }) +} else { + console.debug('[enterprise] submodule not present (OSS-only build)') +} + app.mount('#app') diff --git a/src/frontend/src/stores/enterprise.js b/src/frontend/src/stores/enterprise.js new file mode 100644 index 000000000..4237ee271 --- /dev/null +++ b/src/frontend/src/stores/enterprise.js @@ -0,0 +1,65 @@ +/** + * Enterprise feature-flags store (#847 Phase 0). + * + * Caches the list of entitled enterprise features fetched from + * `GET /api/settings/feature-flags` (`enterprise_features` field). + * Consumed by: + * - NavBar.vue + route guards — hide enterprise tabs when not entitled + * - Enterprise views — show "feature not licensed" hint + * + * The fetch is gated by `featureFlagsLoaded` so the network call fires + * once per page load. Force-refresh available via `force = true`. + * + * Why a separate store (not extending `sessions.js` or `auth.js`): + * - Enterprise flags are cross-cutting — they affect navigation, + * not chat sessions or auth specifically. + * - Allows the enterprise submodule to bind to this store cleanly + * when it loads (`useEnterpriseStore()` from inside enterprise/...). + */ +import { defineStore } from 'pinia' +import axios from 'axios' +import { useAuthStore } from './auth' + +export const useEnterpriseStore = defineStore('enterprise', { + state: () => ({ + featureFlagsLoaded: false, + enterpriseFeatures: [], // e.g. ['sso', 'scim', 'siem'] + }), + + getters: { + isEntitled: (state) => (featureId) => state.enterpriseFeatures.includes(featureId), + hasAnyEnterprise: (state) => state.enterpriseFeatures.length > 0, + }, + + actions: { + async loadFeatureFlags(force = false) { + if (this.featureFlagsLoaded && !force) return + const authStore = useAuthStore() + // Only fetch if authenticated — endpoint requires a JWT. + if (!authStore.isAuthenticated) { + this.enterpriseFeatures = [] + this.featureFlagsLoaded = true + return + } + try { + const r = await axios.get('/api/settings/feature-flags', { + headers: authStore.authHeader, + }) + this.enterpriseFeatures = Array.isArray(r.data?.enterprise_features) + ? r.data.enterprise_features + : [] + } catch (e) { + console.warn('[enterprise] failed to load feature-flags:', e?.message || e) + this.enterpriseFeatures = [] + } finally { + this.featureFlagsLoaded = true + } + }, + + // Test-only seam. + _setFeaturesForTest(features) { + this.enterpriseFeatures = features + this.featureFlagsLoaded = true + }, + }, +}) diff --git a/tests/unit/test_847_entitlement_seam.py b/tests/unit/test_847_entitlement_seam.py index 7129d5109..fecbb3aff 100644 --- a/tests/unit/test_847_entitlement_seam.py +++ b/tests/unit/test_847_entitlement_seam.py @@ -182,8 +182,13 @@ def test_main_py_uses_conditional_enterprise_import(): OSS-only builds (no submodule) start cleanly.""" src = (_BACKEND / "main.py").read_text(encoding="utf-8") # Find the import line - idx = src.find("from enterprise import register_enterprise") - assert idx != -1, "main.py must import register_enterprise from enterprise" + idx = src.find("from enterprise.backend import register_enterprise") + assert idx != -1, ( + "main.py must import register_enterprise from `enterprise.backend` " + "(the private repo is dual-mounted at src/backend/enterprise/ and " + "src/frontend/src/enterprise/; backend Python imports the `backend/` " + "subdir)" + ) # The preceding ~150 chars should contain `try:` window = src[max(0, idx - 200) : idx] assert "try:" in window, ( From 4855233a570a7e0de38b0f8caaede81d0ccae638 Mon Sep 17 00:00:00 2001 From: Oleksii Dolhov Date: Fri, 22 May 2026 12:55:02 +0300 Subject: [PATCH 03/17] refactor(enterprise): frontend in OSS, EntitlementService registry (#847) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reframe the open-core boundary: enterprise FRONTEND lives in the public OSS bundle and is gated server-side via the `enterprise_features` feature-flag. Only the private backend stays behind the submodule. Vue components have no algorithmic IP — the moat is the private backend logic (license verify, SAML signature checks, OAuth flows). This collapses the previous "dual-mount" complexity (same private repo at two mount points) back to a single submodule mount, and matches the existing feature-flag pattern (`session_tab_enabled`, `voice_available`). Trade-off accepted: enterprise Vue source is readable by anyone with the public repo, but the static UI files are not the load-bearing IP. The new closure mechanism is the EntitlementService **registry**: - Each enterprise backend module calls `entitlement_service.register_module(feature_id)` on boot. - `list_entitled_features()` returns the registered set (sorted). - OSS-only builds never call `register_module` → empty set → `enterprise_features: []` → OSS frontend hides every enterprise surface. - `TRINITY_OSS_ONLY=1` is a hard override (denies even when modules ARE registered). Closes three Phase-0 issues: 1. OSS users no longer see broken "Enterprise" nav — registry is empty without the submodule, feature-flag empty, NavBar hides. 2. Adding non-SSO features (SCIM, SIEM) is now additive: ship Vue file in OSS, add backend module, call `register_module(id)`. 3. Login-page SSO button extension point is straightforward — same pattern: OSS Login.vue reads the providers list from a backend API when entitled (out of scope for this PR, but the seam exists). Changes: Public repo: * `src/backend/services/entitlement_service.py` — replaces hardcoded `["sso","scim","siem"]` with a registry. `register_module(id)`, `is_entitled(id)`, `list_entitled_features()` all read from the set. Idempotent registration. TRINITY_OSS_ONLY=1 still hard- overrides everything. * `src/frontend/src/views/enterprise/SSO.vue` (new) — Vue PoC view, moved from the private repo's `frontend/views/EnterpriseSSO.vue`. Same UI; api.js import path adjusted to the OSS location (`../../api`). * `src/frontend/src/router/index.js` — static route entry for `/enterprise/sso` with `meta.requiresEntitlement: 'sso'`. `beforeEach` guard checks the entitlement store before navigation; redirects to `/` when not entitled (defence-in-depth against direct URL visits). * `src/frontend/src/main.js` — drops the `import.meta.glob('./enterprise/frontend/...')` block. Routes are static now. * `.gitmodules` — removes the `src/frontend/src/enterprise/` submodule entry. Only `src/backend/enterprise/` remains. * `src/frontend/src/enterprise` (submodule pointer) — deleted. * CI workflow `build-without-submodule.yml` — checks only the single backend mount; OSS-only frontend Vue files still bundle. * Docs (architecture, local-dev, requirements §34.1) — updated structure diagram, rationale, mount layout. * Tests (`test_847_entitlement_seam.py`) — registry contract: empty-by-default denies, `register_module` enables, idempotent. Updated allow-path test to `register_module("sso")` before asserting. Private repo (sibling commit, not in this PR): * `frontend/` subdir removed (moved to OSS). * `backend/__init__.py` now calls `entitlement_service.register_module("sso")` after mounting the SSO router. Live verification (local): * Default mode: `GET /api/settings/feature-flags` → `enterprise_features: ["sso"]` (only registered modules). `GET /api/enterprise/sso/providers` → `[]`. Enterprise nav link visible. * `TRINITY_OSS_ONLY=1`: `enterprise_features: []`, `GET /api/enterprise/sso/providers` → 403, nav link hidden. * Restored default — re-entitled. Tests: 13 passed + 2 skipped (no-passlib local). Related to #847 Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build-without-submodule.yml | 23 ++--- .gitmodules | 3 - docs/dev/ENTERPRISE_LOCAL_DEV.md | 46 ++++----- docs/memory/requirements.md | 65 ++++++------ docs/planning/ENTERPRISE_ARCHITECTURE.md | 98 ++++++++++--------- src/backend/enterprise | 2 +- src/backend/services/entitlement_service.py | 78 +++++++++++---- src/frontend/src/enterprise | 1 - src/frontend/src/main.js | 30 ------ src/frontend/src/router/index.js | 29 +++++- src/frontend/src/views/enterprise/SSO.vue | 80 +++++++++++++++ tests/unit/test_847_entitlement_seam.py | 67 +++++++++---- 12 files changed, 329 insertions(+), 193 deletions(-) delete mode 160000 src/frontend/src/enterprise create mode 100644 src/frontend/src/views/enterprise/SSO.vue diff --git a/.github/workflows/build-without-submodule.yml b/.github/workflows/build-without-submodule.yml index 80fb57ed3..8fae03119 100644 --- a/.github/workflows/build-without-submodule.yml +++ b/.github/workflows/build-without-submodule.yml @@ -49,20 +49,17 @@ jobs: fetch-depth: 1 persist-credentials: false - - name: Confirm enterprise submodules are absent (both mounts) + - name: Confirm enterprise submodule is absent run: | - # Two mount points (#847 Phase 0.5 — dual-mount). Both must - # be empty when --submodules=false. The expected layout - # under each mount when populated: - # src/backend/enterprise/backend/__init__.py (Python) - # src/frontend/src/enterprise/frontend/index.js (Vite) - for path in src/backend/enterprise/backend/__init__.py src/frontend/src/enterprise/frontend/index.js; do - if [ -f "$path" ]; then - echo "::error::$path present — checkout pulled the submodule. The job must run without it." - exit 1 - fi - done - echo "OK: enterprise submodules absent as expected (both mount points empty)" + # Single mount point (#847). Enterprise frontend ships in + # the OSS bundle and is gated server-side via feature-flags; + # the only submodule is the private backend at + # src/backend/enterprise/. + if [ -f src/backend/enterprise/backend/__init__.py ]; then + echo "::error::src/backend/enterprise/backend/__init__.py present — checkout pulled the submodule. The job must run without it." + exit 1 + fi + echo "OK: enterprise submodule absent as expected" - name: Generate ephemeral secrets for boot run: | diff --git a/.gitmodules b/.gitmodules index 92c294d3d..6398a43c6 100644 --- a/.gitmodules +++ b/.gitmodules @@ -6,6 +6,3 @@ [submodule "src/backend/enterprise"] path = src/backend/enterprise url = git@github.com:Abilityai/trinity-enterprise.git -[submodule "src/frontend/src/enterprise"] - path = src/frontend/src/enterprise - url = git@github.com:Abilityai/trinity-enterprise.git diff --git a/docs/dev/ENTERPRISE_LOCAL_DEV.md b/docs/dev/ENTERPRISE_LOCAL_DEV.md index 52674a4a9..89d1670aa 100644 --- a/docs/dev/ENTERPRISE_LOCAL_DEV.md +++ b/docs/dev/ENTERPRISE_LOCAL_DEV.md @@ -38,22 +38,21 @@ submodules in: git submodule update --init --recursive ``` -`.gitmodules` mounts three private submodules (the enterprise repo is -**dual-mounted** — same URL, two paths, so backend and frontend each -get a clean import surface): +`.gitmodules` mounts two private submodules: -| Submodule | Path | Consumed by | Subdir read | -|---|---|---|---| -| `.claude` | `.claude/` | Claude Code skills (`/sprint`, `/cso`, …) | (all) | -| `src/backend/enterprise` | `src/backend/enterprise/` | Python (`main.py`) | `backend/` | -| `src/frontend/src/enterprise` | `src/frontend/src/enterprise/` | Vite (`main.js`) | `frontend/` | +| Submodule | Path | Consumed by | +|---|---|---| +| `.claude` | `.claude/` | Claude Code skills (`/sprint`, `/cso`, …) | +| `src/backend/enterprise` | `src/backend/enterprise/` | Python (`main.py`) — backend logic | -The two enterprise mounts clone the same repo, so disk usage is ~2× -the repo size. In exchange you get a single enterprise codebase to -version, and each consumer imports only the subdir it needs. +**Enterprise frontend lives in the public OSS bundle** at +`src/frontend/src/views/enterprise/` and is gated server-side via the +`enterprise_features` field in `/api/settings/feature-flags`. No +frontend submodule. See `docs/planning/ENTERPRISE_ARCHITECTURE.md` +for the rationale. -Both use SSH (`git@github.com:...`). If you only have HTTPS auth -configured, add an SSH override: +The backend submodule uses SSH (`git@github.com:...`). If you only +have HTTPS auth configured, add an SSH override: ```bash git config --global url."git@github.com:".insteadOf "https://github.com/" @@ -100,33 +99,28 @@ import worked, and the entitlement seam reports all features enabled ## Working on the enterprise repo -The repo is dual-mounted. Pick **one** of the two mounts to make -changes in (typically `src/backend/enterprise/` since that's where -you most often start) — `git push` from there updates the upstream, -then the other mount can `git pull` to sync. - ```bash cd src/backend/enterprise git checkout main # submodules default to detached HEAD -# … make changes (in backend/ or frontend/ subdir) … +# … make changes (under backend/) … git commit -m "feat(sso): ..." git push origin main - -# Sync the other mount -cd ../../../src/frontend/src/enterprise -git checkout main -git pull origin main ``` -Then, back in the public repo root, **bump both submodule pointers**: +Then, back in the public repo root, **bump the submodule pointer**: ```bash cd -git add src/backend/enterprise src/frontend/src/enterprise +git add src/backend/enterprise git commit -m "chore: bump enterprise submodule" git push ``` +For **frontend** changes (Vue views, nav entries), edit the public +repo directly under `src/frontend/src/views/enterprise/`, +`src/frontend/src/stores/enterprise.js`, etc. Hot-reload picks them +up immediately. + ## Forcing OSS-only mode (testing the deny path) ```bash diff --git a/docs/memory/requirements.md b/docs/memory/requirements.md index 31112d82b..a6773e791 100644 --- a/docs/memory/requirements.md +++ b/docs/memory/requirements.md @@ -2367,46 +2367,56 @@ Standalone mobile-friendly admin page for managing agents on the go. Designed as the seam in the public Trinity backend for loading closed-source compliance modules (SSO, SCIM, SIEM) from a private git submodule at `src/backend/enterprise/` pointing to - `Abilityai/trinity-enterprise`. No license code yet; the - `EntitlementService` is a stub that returns True for every - feature_id, gated by `TRINITY_OSS_ONLY` for testing the deny path. + `Abilityai/trinity-enterprise`. Backend-only private; enterprise + Vue components ship in the public OSS bundle and are gated + server-side via the `enterprise_features` list. The + `EntitlementService` is a registry — empty until + `register_enterprise(app)` calls `register_module(feature_id)` for + each loaded enterprise module, which only happens when the + private submodule is actually mounted. `TRINITY_OSS_ONLY=1` is a + hard override for the deny path. - **Decision record**: `docs/planning/ENTERPRISE_ARCHITECTURE.md` - **Long-form research**: `docs/planning/OSS_ENTERPRISE_SPLIT_RESEARCH.md` - **Local-dev guide**: `docs/dev/ENTERPRISE_LOCAL_DEV.md` - **Key Features**: - `EntitlementService` (`src/backend/services/entitlement_service.py`) - — Phase 0 stub returning all-entitled by default. Honours - `TRINITY_OSS_ONLY=1` for forced OSS-only mode (returns False for - every check). Module-level singleton + `_set_for_testing` seam. + — registry pattern. `register_module(feature_id)` populates a set; + `is_entitled()` and `list_entitled_features()` read from it. OSS + builds never call `register_module` → empty set → deny everything. + `TRINITY_OSS_ONLY=1` is a hard override (denies even when + modules ARE registered). - `requires_entitlement(feature_id)` (`src/backend/dependencies.py`) — FastAPI dependency factory mirroring `require_role`. Raises HTTP 403 with the feature_id in the detail string. Lazy-imports the service so tests can swap singletons. - Conditional submodule loader in `src/backend/main.py` — - `try: from enterprise import register_enterprise; + `try: from enterprise.backend import register_enterprise; register_enterprise(app) except ImportError: pass`. OSS-only builds (no submodule) silently no-op with an informational log. - Idempotent via `app.state.enterprise_registered`. + The loader calls `entitlement_service.register_module(...)` for + each registered feature, which drives feature-flag output. - `/api/settings/feature-flags` extended with `enterprise_features: list[str]` — empty in OSS mode, populated - when entitled. UI uses this to hide enterprise-only tabs cleanly - without server-side conditional rendering. - - `.gitmodules` — **dual-mount** of `Abilityai/trinity-enterprise` - at `src/backend/enterprise/` (Python consumes `backend/` subdir) - and `src/frontend/src/enterprise/` (Vite consumes `frontend/` - subdir). Same URL, two paths — keeps the enterprise codebase in - one repo while exposing clean import surfaces for each layer. - - Frontend integration in `src/frontend/src/main.js` — - `import.meta.glob('./enterprise/frontend/index.js', { eager: false })`. - Empty glob → no-op (OSS-only build). When present, the module's - `registerEnterprise(router, app)` runs after Pinia + Router setup - and adds routes via `router.addRoute`. + when the private backend submodule is mounted. The OSS frontend + reads this to decide what enterprise UI to render. + - `.gitmodules` — single submodule at `src/backend/enterprise/` + pointing to `Abilityai/trinity-enterprise` via SSH. Backend only. + - **Enterprise frontend ships in OSS** at + `src/frontend/src/views/enterprise/` — Vue components have no + algorithmic IP, the moat is the private backend logic. Same + feature-flag pattern as `session_tab_enabled` and + `voice_available`. Adding a new enterprise feature = Vue file + in OSS + private backend module + `register_module(id)`. - Pinia store `src/frontend/src/stores/enterprise.js` — caches `enterprise_features: list[str]` from `/api/settings/feature-flags`. Exposes `isEntitled(featureId)` + `hasAnyEnterprise` getters. + - Static route in `src/frontend/src/router/index.js` for + `/enterprise/sso` with `meta.requiresEntitlement: 'sso'`. + `beforeEach` guard redirects to `/` when not entitled + (defence-in-depth against direct URL visits / bookmarks). - `NavBar.vue` — new `Enterprise` link `v-if="enterpriseStore.isEntitled('sso')"` with a `PRO` badge. - Hidden in OSS-only mode (empty list) and when operator forces + Hidden in OSS-only mode and when operator forces `TRINITY_OSS_ONLY=1`. - `docker-compose.yml` env pass-through for `TRINITY_OSS_ONLY`. - CI workflow `.github/workflows/build-without-submodule.yml` — @@ -2416,21 +2426,16 @@ Standalone mobile-friendly admin page for managing agents on the go. Designed as returns 404, and the OSS-only log line is emitted. Catches regressions where the conditional import accidentally becomes hard-required. -- **Private repo (Abilityai/trinity-enterprise)** — dual-mounted: - - `backend/__init__.py` — `register_enterprise(app)` single FastAPI - entry point. +- **Private repo (Abilityai/trinity-enterprise) — backend only**: + - `backend/__init__.py` — `register_enterprise(app)` mounts + routers AND calls `entitlement_service.register_module(id)` + per feature. - `backend/sso/router.py` — `/api/enterprise/sso/{providers,login/{id}}` stubs gated by `requires_entitlement("sso")`. `GET /providers` returns the in-process registry (empty by default); `POST /login/{id}` returns 501 "PoC stub" or 404 for unknown id. - `backend/sso/providers.py` — `SSOProvider` ABC (provider_id, display_name, protocol, begin_login) + `StubProvider`. - - `frontend/index.js` — `registerEnterprise(router, app)` single - Vue Router entry point. Idempotent (module-local flag). - - `frontend/views/EnterpriseSSO.vue` — Vue view at - `/enterprise/sso`. Fetches `/api/enterprise/sso/providers`, - renders empty-state message in the PoC; provider rows with - disabled "Login (stub)" buttons when the list isn't empty. - `pyproject.toml`, `LICENSE` (proprietary), `README.md`. - **Out of scope (separate follow-ups)**: - Phase 1: Ed25519-signed license token, verify path, admin diff --git a/docs/planning/ENTERPRISE_ARCHITECTURE.md b/docs/planning/ENTERPRISE_ARCHITECTURE.md index eba89b5ca..bd99f0969 100644 --- a/docs/planning/ENTERPRISE_ARCHITECTURE.md +++ b/docs/planning/ENTERPRISE_ARCHITECTURE.md @@ -33,56 +33,58 @@ abilityai/trinity (public, MIT/Apache after decision #6) │ ├── main.py conditional import + register_enterprise(app) │ ├── dependencies.py requires_entitlement(feature_id) — Phase 0 seam │ ├── services/ -│ │ └── entitlement_service.py EntitlementService (Phase 0 stub; Phase 1 license) -│ └── enterprise/ ← submodule mount #1 (Python imports backend/) -│ └── (populated by submodule init — same repo, both subdirs) +│ │ └── entitlement_service.py EntitlementService — registry + license check (Phase 1) +│ └── enterprise/ ← single submodule mount (private backend) +│ └── (populated by submodule init) ├── src/frontend/src/ -│ ├── main.js conditional `import.meta.glob` of enterprise/frontend/ -│ ├── stores/enterprise.js enterprise feature-flags store -│ └── enterprise/ ← submodule mount #2 (Vite imports frontend/) -│ └── (populated by submodule init — same repo, both subdirs) +│ ├── stores/enterprise.js enterprise feature-flags Pinia store +│ ├── views/enterprise/ +│ │ └── SSO.vue #847 PoC view — lives in OSS, gated by feature-flag +│ ├── components/NavBar.vue v-if="enterpriseStore.isEntitled(...)" per link +│ └── router/index.js route guard checks meta.requiresEntitlement ├── docs/planning/ │ ├── ENTERPRISE_ARCHITECTURE.md this file │ └── OSS_ENTERPRISE_SPLIT_RESEARCH.md long-form research └── docs/dev/ └── ENTERPRISE_LOCAL_DEV.md 15-min onboarding guide -Abilityai/trinity-enterprise (private, proprietary, dual-mounted) +Abilityai/trinity-enterprise (private, proprietary, backend only) ├── backend/ │ ├── __init__.py register_enterprise(app) entry point +│ │ + entitlement_service.register_module() per feature │ └── sso/ #847 PoC (router + provider ABC + stubs) -├── frontend/ -│ ├── index.js registerEnterprise(router, app) entry point -│ └── views/ -│ └── EnterpriseSSO.vue #847 PoC (Vue component) └── LICENSE commercial / proprietary ``` -### Why two mount points of the same repo (not two repos) +### Why backend-only private (frontend ships in OSS) -Symmetric to the backend/frontend split in the public repo — keeping -the enterprise code in **one** private repo means a single version -bump touches both backend and frontend together. The mild disk -duplication (~1 MB cloned twice) is far cheaper than two repos -drifting out of sync. +Vue components for enterprise views (forms, layouts, copy) have no +algorithmic IP. The real moat is in the **backend** — license +verification, SAML signature checks, OAuth flows, SCIM endpoint +implementations. Those stay private. The frontend ships in the OSS +bundle and is gated purely server-side via the +`enterprise_features` list returned at +`GET /api/settings/feature-flags`. Same shape as existing flags +(`session_tab_enabled`, `voice_available`, `workspace_available`): +the server flips a bit, the OSS frontend hides every related surface. -`.gitmodules` declares both mounts at the same URL but different -paths: +The registry primitive on `EntitlementService` is what closes the +loop. Each enterprise backend module calls +`entitlement_service.register_module(feature_id)` on boot; OSS-only +builds never reach that code → the registry stays empty → +`list_entitled_features()` returns `[]` → the frontend hides every +enterprise nav entry, login button, and view. Adding a new feature +is purely additive (Vue file in OSS + private backend module). + +`.gitmodules` declares one mount: ```ini [submodule "src/backend/enterprise"] path = src/backend/enterprise url = git@github.com:Abilityai/trinity-enterprise.git -[submodule "src/frontend/src/enterprise"] - path = src/frontend/src/enterprise - url = git@github.com:Abilityai/trinity-enterprise.git ``` -Each consumer reads only its own subdir: -- Python `from enterprise.backend import register_enterprise` resolves - to `src/backend/enterprise/backend/__init__.py`. -- Vite `import.meta.glob('./enterprise/frontend/index.js')` resolves to - `src/frontend/src/enterprise/frontend/index.js`. +Python imports as `from enterprise.backend import register_enterprise`. ## Why a submodule, not a Python package @@ -123,26 +125,32 @@ job below proves the conditional import works. OSS-only (UI hides enterprise tabs). **`.gitmodules`** -- Two submodule entries (`src/backend/enterprise` + `src/frontend/src/enterprise`) - same URL pointing at the private repo via SSH. +- Single submodule entry at `src/backend/enterprise` pointing at the + private repo via SSH. **`docker-compose.yml`** - Pass-through for `TRINITY_OSS_ONLY` env var. -**`src/frontend/src/main.js`** -- Conditional `import.meta.glob('./enterprise/frontend/index.js')`. - Empty in OSS-only builds; module's `registerEnterprise(router, app)` - runs when present. - **`src/frontend/src/stores/enterprise.js`** (new) - Pinia store. Loads `/api/settings/feature-flags` after auth, caches `enterprise_features: list[str]`. Exposes `isEntitled(featureId)` and `hasAnyEnterprise` getters. +**`src/frontend/src/views/enterprise/SSO.vue`** (new — in OSS) +- PoC view at route `/enterprise/sso`. Fetches + `/api/enterprise/sso/providers`, renders empty state in the PoC. + Lives in the OSS bundle; route is statically registered in + `router/index.js`. + +**`src/frontend/src/router/index.js`** +- Static route entry with `meta.requiresEntitlement: 'sso'`. + `beforeEach` guard checks the entitlement store and redirects to + `/` when not entitled (defence-in-depth against direct URL visits). + **`src/frontend/src/components/NavBar.vue`** - New `Enterprise` nav link `v-if="enterpriseStore.isEntitled('sso')"`. - Hidden in OSS-only builds (empty list) and when the operator forces - `TRINITY_OSS_ONLY=1`. + Hidden in OSS-only builds (registry empty → `enterprise_features: []`) + and when the operator forces `TRINITY_OSS_ONLY=1`. ## What the private repo holds (`Abilityai/trinity-enterprise`) @@ -150,18 +158,16 @@ PoC scope (this PR): | File | Purpose | |---|---| -| `backend/__init__.py` | `register_enterprise(app)` — FastAPI integration entry | -| `backend/sso/router.py` | `/api/enterprise/sso/{providers,login/{id}}` — stubs, gated by `requires_entitlement("sso")` | -| `backend/sso/providers.py` | `SSOProvider` ABC + `StubProvider` for the PoC registry | -| `frontend/index.js` | `registerEnterprise(router, app)` — Vue Router integration entry | -| `frontend/views/EnterpriseSSO.vue` | Vue component — providers list view (empty state in PoC) | +| `backend/__init__.py` | `register_enterprise(app)` + `entitlement_service.register_module(...)` calls per feature | +| `backend/sso/router.py` | `/api/enterprise/sso/{providers,login/{id}}` stubs, gated by `requires_entitlement("sso")` | +| `backend/sso/providers.py` | `SSOProvider` ABC + `StubProvider` | | `pyproject.toml` | Metadata (no pip-install mode yet — submodule mount only) | | `LICENSE` | Proprietary | ## How CI handles "build without submodule" Workflow `.github/workflows/build-without-submodule.yml` boots the -backend image with both submodule mounts **absent** and asserts: +backend image with the enterprise submodule **absent** and asserts: 1. Container starts cleanly 2. `GET /api/settings/feature-flags` returns `enterprise_features: []` @@ -170,9 +176,9 @@ backend image with both submodule mounts **absent** and asserts: This proves the conditional import doesn't break OSS-only deployments when the submodule URL access is revoked or the submodule is unchecked. -The frontend side is symmetric: `import.meta.glob` returns `{}` when -`src/frontend/src/enterprise/` is empty, so `main.js` silently no-ops -and no enterprise route ever registers. +The OSS frontend's Vue files for enterprise views are still in the +bundle but every nav entry / link is hidden by the empty +`enterprise_features` list. ## What's NOT in this PR (open follow-ups) diff --git a/src/backend/enterprise b/src/backend/enterprise index c8b46598c..67818eff5 160000 --- a/src/backend/enterprise +++ b/src/backend/enterprise @@ -1 +1 @@ -Subproject commit c8b46598c8bfccd20c72f79a8d7001445e5236e6 +Subproject commit 67818eff55fcb2432e27ab6fc9c71e9b75cbeb7a diff --git a/src/backend/services/entitlement_service.py b/src/backend/services/entitlement_service.py index 4cc81b877..944111604 100644 --- a/src/backend/services/entitlement_service.py +++ b/src/backend/services/entitlement_service.py @@ -40,49 +40,85 @@ class gets the verification logic and every gated endpoint picks it up class EntitlementService: - """In-process entitlement check. - - Phase 0 (this PR): all features entitled. The ``oss_only`` mode - (env ``TRINITY_OSS_ONLY=1``) flips every check to False, useful for - operators who want to lock down a Trinity instance to the OSS - surface even when the enterprise submodule is mounted (e.g. for - a clean compliance posture in a free-tier deployment). + """In-process entitlement check + module registry. + + Backed by an internal set of *registered* enterprise modules + populated when ``enterprise.backend.register_enterprise(app)`` + runs in ``main.py``. OSS-only builds never call + ``register_module()`` → the registry stays empty → both + ``is_entitled()`` and ``list_entitled_features()`` deny everything + → the OSS frontend's `enterprise_features`-driven UI hides every + enterprise nav entry / login button / view automatically (same + pattern as `session_tab_enabled` and `voice_available`). + + ``TRINITY_OSS_ONLY=1`` is a hard override that empties the + registry-derived list even when the enterprise submodule IS + mounted — useful for operators who want a compliance lockdown + or for CI builds exercising the deny path. + + Phase 1 will layer a license-claim check on top of the registry: + ``is_entitled(f) = f in registered AND f in license_claims``. """ def __init__(self) -> None: - # When TRINITY_OSS_ONLY=1, deny every feature. Use cases: - # operators running the public repo without enterprise, who - # still want UI affordances to hide enterprise tabs; CI builds - # that exercise the deny path. + # When TRINITY_OSS_ONLY=1, deny every feature regardless of + # what's registered. Use cases: operators running with the + # submodule present but wanting compliance lockdown; CI + # builds testing the deny path. self._oss_only = os.getenv("TRINITY_OSS_ONLY", "0").lower() in {"1", "true", "yes"} + # Module registry. Populated by `register_module()` from + # `enterprise.backend.register_enterprise(app)`. OSS-only + # builds never reach that code → stays empty → returns False + # from `is_entitled()` for every feature. + self._registered_modules: set[str] = set() if self._oss_only: logger.info( "[EntitlementService] TRINITY_OSS_ONLY=1 — all enterprise " "features will report as not-entitled" ) + def register_module(self, feature_id: str) -> None: + """Register an enterprise module by its feature_id. + + Called from the private repo's + ``enterprise.backend.register_enterprise(app)`` for each + module it mounts. The registry drives + ``list_entitled_features()`` so the OSS frontend hides + surfaces for features that aren't actually present in this + build. + + Idempotent — safe to call twice with the same feature_id. + """ + if feature_id in self._registered_modules: + return + self._registered_modules.add(feature_id) + logger.info( + f"[EntitlementService] registered enterprise module: {feature_id!r} " + f"(total: {len(self._registered_modules)})" + ) + def is_entitled(self, feature_id: str) -> bool: - """Return True if the named feature is licensed for this instance. + """Return True if the named feature is licensed AND registered. - Phase 0: True for any feature_id unless OSS-only mode is set. - Phase 1: cross-checks the license claim set. + Phase 0: True iff the feature is in the registry (registered + by the private submodule on boot) AND OSS-only mode is not + set. Phase 1: also cross-checks the license claim set. """ if self._oss_only: return False - # Phase 1 will replace this with a real license check. - return True + return feature_id in self._registered_modules def list_entitled_features(self) -> list[str]: """Return the set of feature IDs this instance is entitled to. - Used by ``GET /api/settings/feature-flags`` to drive UI tabs. - Phase 0: returns the well-known feature IDs when entitled. + Used by ``GET /api/settings/feature-flags`` to drive UI tab + visibility. Returns the registered modules in sorted order + (deterministic for tests + UI ordering). OSS-only builds + return ``[]`` because nothing ever registered. """ if self._oss_only: return [] - # Phase 0: the set known to the seam. Real implementations - # would derive from license claims. - return ["sso", "scim", "siem"] + return sorted(self._registered_modules) # Module-level singleton — what `dependencies.requires_entitlement` calls. diff --git a/src/frontend/src/enterprise b/src/frontend/src/enterprise deleted file mode 160000 index c8b46598c..000000000 --- a/src/frontend/src/enterprise +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c8b46598c8bfccd20c72f79a8d7001445e5236e6 diff --git a/src/frontend/src/main.js b/src/frontend/src/main.js index e71d39119..a33935b7a 100644 --- a/src/frontend/src/main.js +++ b/src/frontend/src/main.js @@ -40,34 +40,4 @@ axios.interceptors.response.use( } ) -// #847 Phase 0 — load enterprise frontend module if present. The -// submodule at `src/frontend/src/enterprise/` is OPTIONAL — OSS-only -// builds clone without it and the glob below returns an empty object, -// so this block silently no-ops. When the submodule IS mounted, its -// `frontend/index.js` exports `registerEnterprise(router, app)` which -// calls `router.addRoute(...)` for each enterprise view. Routes carry -// `meta.requiresEntitlement` so the nav bar can hide them when the -// auth store reports the feature is not entitled. -// -// `eager: false` keeps the module out of the initial bundle — it's -// fetched only when the import is called. The route guard double- -// checks entitlement (defense in depth against a mounted-but-not- -// licensed scenario). -const enterpriseModules = import.meta.glob('./enterprise/frontend/index.js', { eager: false }) -const enterpriseEntry = Object.values(enterpriseModules)[0] -if (enterpriseEntry) { - enterpriseEntry().then((mod) => { - if (typeof mod.registerEnterprise === 'function') { - mod.registerEnterprise(router, app) - console.info('[enterprise] frontend module loaded') - } else { - console.warn('[enterprise] frontend/index.js did not export registerEnterprise') - } - }).catch((err) => { - console.error('[enterprise] failed to load frontend module:', err) - }) -} else { - console.debug('[enterprise] submodule not present (OSS-only build)') -} - app.mount('#app') diff --git a/src/frontend/src/router/index.js b/src/frontend/src/router/index.js index 85081e68e..e04968e94 100644 --- a/src/frontend/src/router/index.js +++ b/src/frontend/src/router/index.js @@ -108,6 +108,19 @@ const routes = [ path: '/network', redirect: '/' }, + // #847 Phase 0 — enterprise SSO admin view. The Vue source ships in + // the OSS bundle but the route is gated by `requiresEntitlement` + // ('sso' must be in `enterpriseStore.enterpriseFeatures`). NavBar + // hides the link when not entitled. Backend `/api/enterprise/sso/*` + // returns 404 in OSS-only builds (no submodule mounted), so even a + // direct URL visit shows the view's empty/error state cleanly. See + // `docs/planning/ENTERPRISE_ARCHITECTURE.md`. + { + path: '/enterprise/sso', + name: 'EnterpriseSSO', + component: () => import('../views/enterprise/SSO.vue'), + meta: { requiresAuth: true, requiresEntitlement: 'sso' } + }, // Mobile Admin PWA (MOB-001) — standalone, no NavBar { path: '/m', @@ -186,7 +199,21 @@ router.beforeEach(async (to, from, next) => { // Check if route requires authentication if (to.meta.requiresAuth) { if (authStore.isAuthenticated) { - // User is authenticated, allow access + // #847 — enterprise entitlement guard. For routes carrying + // `meta.requiresEntitlement`, ensure the named feature is in + // the server-driven entitlement list before navigation. NavBar + // already hides links to non-entitled routes; this guard + // catches direct URL visits / bookmarks. + const entitlement = to.meta.requiresEntitlement + if (entitlement) { + const { useEnterpriseStore } = await import('../stores/enterprise') + const enterpriseStore = useEnterpriseStore() + await enterpriseStore.loadFeatureFlags() + if (!enterpriseStore.isEntitled(entitlement)) { + next('/') + return + } + } next() } else { // User is not authenticated, redirect to login diff --git a/src/frontend/src/views/enterprise/SSO.vue b/src/frontend/src/views/enterprise/SSO.vue new file mode 100644 index 000000000..73d1c45d0 --- /dev/null +++ b/src/frontend/src/views/enterprise/SSO.vue @@ -0,0 +1,80 @@ + + + diff --git a/tests/unit/test_847_entitlement_seam.py b/tests/unit/test_847_entitlement_seam.py index fecbb3aff..5f2324515 100644 --- a/tests/unit/test_847_entitlement_seam.py +++ b/tests/unit/test_847_entitlement_seam.py @@ -37,30 +37,46 @@ # ----------------------------------------------------------------------------- -def test_default_is_entitled_returns_true(monkeypatch): - """Stub mode (no TRINITY_OSS_ONLY): every feature_id is entitled.""" +def test_empty_registry_denies_every_feature(monkeypatch): + """Default (no `register_module` calls): everything denied. + This is the OSS-only build state — no enterprise submodule was + mounted, so `register_enterprise(app)` never ran, so the + registry stays empty.""" monkeypatch.delenv("TRINITY_OSS_ONLY", raising=False) from services.entitlement_service import EntitlementService svc = EntitlementService() + assert svc.is_entitled("sso") is False + assert svc.is_entitled("scim") is False + assert svc.is_entitled("siem") is False + assert svc.list_entitled_features() == [] + + +def test_register_module_then_entitled(monkeypatch): + """After `register_module("sso")`, "sso" is entitled and listed.""" + monkeypatch.delenv("TRINITY_OSS_ONLY", raising=False) + from services.entitlement_service import EntitlementService + + svc = EntitlementService() + svc.register_module("sso") + svc.register_module("scim") + assert svc.is_entitled("sso") is True assert svc.is_entitled("scim") is True - assert svc.is_entitled("siem") is True - # Unknown features also return True in stub mode — the seam doesn't - # pretend to know the catalogue yet (that's Phase 1 license claims). - assert svc.is_entitled("not-a-real-feature") is True + assert svc.is_entitled("siem") is False # not registered + assert svc.list_entitled_features() == ["scim", "sso"] # sorted -def test_default_list_entitled_features(monkeypatch): - """Stub mode reports the known enterprise feature catalogue.""" +def test_register_module_is_idempotent(monkeypatch): + """Calling register_module twice with the same id doesn't grow + the list (idempotency contract from the docstring).""" monkeypatch.delenv("TRINITY_OSS_ONLY", raising=False) from services.entitlement_service import EntitlementService svc = EntitlementService() - features = svc.list_entitled_features() - assert "sso" in features - assert "scim" in features - assert "siem" in features + svc.register_module("sso") + svc.register_module("sso") # second call should be a no-op + assert svc.list_entitled_features() == ["sso"] # ----------------------------------------------------------------------------- @@ -69,30 +85,31 @@ def test_default_list_entitled_features(monkeypatch): @pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes"]) -def test_oss_only_denies_every_feature(monkeypatch, value): - """Any truthy spelling of TRINITY_OSS_ONLY flips all checks False.""" +def test_oss_only_denies_every_feature_even_when_registered(monkeypatch, value): + """TRINITY_OSS_ONLY hard-overrides the registry. Even after + `register_module("sso")`, the deny path fires.""" monkeypatch.setenv("TRINITY_OSS_ONLY", value) - # Reimport so the constructor re-reads the env var. if "services.entitlement_service" in sys.modules: del sys.modules["services.entitlement_service"] from services.entitlement_service import EntitlementService svc = EntitlementService() + svc.register_module("sso") # the override wins regardless assert svc.is_entitled("sso") is False - assert svc.is_entitled("scim") is False assert svc.list_entitled_features() == [] @pytest.mark.parametrize("value", ["0", "false", "no", ""]) -def test_oss_only_falsy_keeps_entitlements(monkeypatch, value): - """Falsy spellings (and empty string) leave the default stub - behaviour intact.""" +def test_oss_only_falsy_keeps_registry_behaviour(monkeypatch, value): + """Falsy spellings leave the registry behaviour intact.""" monkeypatch.setenv("TRINITY_OSS_ONLY", value) if "services.entitlement_service" in sys.modules: del sys.modules["services.entitlement_service"] from services.entitlement_service import EntitlementService svc = EntitlementService() + assert svc.is_entitled("sso") is False # nothing registered yet + svc.register_module("sso") assert svc.is_entitled("sso") is True @@ -116,12 +133,17 @@ def _import_requires_entitlement_or_skip(): def test_requires_entitlement_allows_when_entitled(monkeypatch): - """The Depends() callable returns None on allow.""" + """The Depends() callable returns None on allow. Allow path + requires registering the module first — empty registry denies.""" monkeypatch.delenv("TRINITY_OSS_ONLY", raising=False) if "services.entitlement_service" in sys.modules: del sys.modules["services.entitlement_service"] requires_entitlement = _import_requires_entitlement_or_skip() + # Register "sso" so the dependency allows the call. + from services.entitlement_service import entitlement_service + entitlement_service.register_module("sso") + inner = requires_entitlement("sso") assert inner() is None @@ -168,7 +190,10 @@ def list_entitled_features(self): assert ent_mod.entitlement_service.list_entitled_features() == [] finally: ent_mod._set_for_testing(None) # restore default - # Restored — stub-default singleton behaviour returns True again + # Restored — fresh default singleton has empty registry, so + # still False until something calls register_module(). + assert ent_mod.entitlement_service.is_entitled("sso") is False + ent_mod.entitlement_service.register_module("sso") assert ent_mod.entitlement_service.is_entitled("sso") is True From 07b6564bdfddb82f5a1edd71309ee8274b40b8e6 Mon Sep 17 00:00:00 2001 From: Oleksii Dolhov Date: Fri, 22 May 2026 13:18:54 +0300 Subject: [PATCH 04/17] feat(enterprise): SSO admin UI mock + Login page provider buttons (#847) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PoC enhancement to make the SSO surface demo-realistic. Backend seeds two mock providers (Okta + Azure AD) and exposes three new read endpoints (claim-mapping, session-policy) the OSS admin page renders. All action buttons remain disabled with tooltips pointing at issue #847 — no real OIDC/SAML implementation yet. Public repo changes (this commit): * `src/frontend/src/views/enterprise/Index.vue` (new) — Enterprise catalogue landing page. Cards layout with status badges (SSO Available; SCIM/SIEM/License/Audit "Coming soon"). Linked from NavBar's `Enterprise` link. * `src/frontend/src/views/enterprise/SSO.vue` (rewrite) — full admin UI: - Header with "+ Add provider" button (opens modal) - Configured providers list with protocol badge, enabled/disabled indicator, issuer/metadata URL, last-login timestamp, and disabled Test/Edit/⋮ actions per row - Identity → Role Mapping table (4 rules: trinity-admins→admin, trinity-developers→creator, trinity-readonly→user, fallback) - Session Policy panel (force-SSO checkbox, session lifetime, admin-reauth checkbox — all disabled) - Add provider modal: protocol radio (OIDC/SAML), display name, provider ID, issuer URL, client ID/secret, scopes, callback URL with Copy button, enabled-on-save toggle, Cancel + Save buttons (Save disabled) * `src/frontend/src/views/Login.vue` — adds an "or sign in with" section under the email form when SSO providers are reachable. Fetches /api/enterprise/sso/providers unauthenticated (endpoint is gated by entitlement, not by user auth, so the pre-login page can call it). Two stub buttons render: "Continue with Okta" / "Continue with Azure AD". Both disabled with tooltip. * `src/frontend/src/router/index.js` — landing route + per-feature routes with two gate modes: - `meta.requiresAnyEntitlement` for the catalogue landing - `meta.requiresEntitlement: ''` for per-feature pages Guard bounces non-entitled feature visits to /enterprise (when any feature is entitled) or / (otherwise). * `src/frontend/src/components/NavBar.vue` — link points at the catalogue landing (`/enterprise`). `v-if` uses `hasAnyEnterprise` so the Enterprise nav entry shows whenever any enterprise feature is registered. * `src/backend/enterprise` (submodule bump) — pulls in private repo's `feat(sso): seed PoC providers + claim-mapping + session-policy endpoints` (commit 3e90ddc). Live verification: - Default (submodule mounted): * `/api/enterprise/sso/providers` → 2 providers * `/api/enterprise/sso/claim-mapping` → 4 rules * `/api/enterprise/sso/session-policy` → SessionPolicy defaults * `/enterprise` landing renders 5 cards (SSO available, others "Coming soon") * `/enterprise/sso` renders full admin UI with all buttons disabled * `/login` shows "Continue with Okta/Azure AD" buttons under email form - `TRINITY_OSS_ONLY=1`: providers endpoint 403, NavBar enterprise link hidden, Login SSO buttons hidden. Related to #847 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/backend/enterprise | 2 +- src/frontend/src/components/NavBar.vue | 21 +- src/frontend/src/router/index.js | 49 ++- src/frontend/src/views/Login.vue | 62 ++++ src/frontend/src/views/enterprise/Index.vue | 147 ++++++++ src/frontend/src/views/enterprise/SSO.vue | 358 +++++++++++++++++--- 6 files changed, 572 insertions(+), 67 deletions(-) create mode 100644 src/frontend/src/views/enterprise/Index.vue diff --git a/src/backend/enterprise b/src/backend/enterprise index 67818eff5..3e90ddc73 160000 --- a/src/backend/enterprise +++ b/src/backend/enterprise @@ -1 +1 @@ -Subproject commit 67818eff55fcb2432e27ab6fc9c71e9b75cbeb7a +Subproject commit 3e90ddc732888caebb55101db8d8479b99f717e7 diff --git a/src/frontend/src/components/NavBar.vue b/src/frontend/src/components/NavBar.vue index 05528f7a6..057f525a4 100644 --- a/src/frontend/src/components/NavBar.vue +++ b/src/frontend/src/components/NavBar.vue @@ -66,15 +66,18 @@ > Settings - + @@ -280,7 +283,7 @@ const themeStore = useThemeStore() const notificationsStore = useNotificationsStore() const operatorQueueStore = useOperatorQueueStore() // #847 Phase 0 — feature-flags load is fired on mount below; the -// `Enterprise` nav link template is `v-if="enterpriseStore.isEntitled('sso')"`. +// `Enterprise` nav link template is `v-if="enterpriseStore.hasAnyEnterprise"`. const enterpriseStore = useEnterpriseStore() const { isConnected } = useWebSocket() diff --git a/src/frontend/src/router/index.js b/src/frontend/src/router/index.js index e04968e94..3e07f51d7 100644 --- a/src/frontend/src/router/index.js +++ b/src/frontend/src/router/index.js @@ -108,13 +108,25 @@ const routes = [ path: '/network', redirect: '/' }, - // #847 Phase 0 — enterprise SSO admin view. The Vue source ships in - // the OSS bundle but the route is gated by `requiresEntitlement` - // ('sso' must be in `enterpriseStore.enterpriseFeatures`). NavBar - // hides the link when not entitled. Backend `/api/enterprise/sso/*` - // returns 404 in OSS-only builds (no submodule mounted), so even a - // direct URL visit shows the view's empty/error state cleanly. See - // `docs/planning/ENTERPRISE_ARCHITECTURE.md`. + // #847 Phase 0 — enterprise tab. The Vue source ships in the OSS + // bundle but routes are gated by `requiresEntitlement` (must be in + // `enterpriseStore.enterpriseFeatures`). Backend + // `/api/enterprise/*` returns 404 in OSS-only builds (no submodule + // mounted), so even a direct URL visit shows the view's empty + // state cleanly. See `docs/planning/ENTERPRISE_ARCHITECTURE.md`. + // + // Landing page uses the special `requiresAnyEntitlement: true` + // marker — visible whenever ANY enterprise feature is entitled, + // so OSS users with one of {sso, scim, siem, …} can reach the + // catalogue overview. Per-feature routes below use the specific + // `requiresEntitlement: ''` so non-entitled features 302 to + // the catalogue. + { + path: '/enterprise', + name: 'EnterpriseLanding', + component: () => import('../views/enterprise/Index.vue'), + meta: { requiresAuth: true, requiresAnyEntitlement: true } + }, { path: '/enterprise/sso', name: 'EnterpriseSSO', @@ -199,17 +211,28 @@ router.beforeEach(async (to, from, next) => { // Check if route requires authentication if (to.meta.requiresAuth) { if (authStore.isAuthenticated) { - // #847 — enterprise entitlement guard. For routes carrying - // `meta.requiresEntitlement`, ensure the named feature is in - // the server-driven entitlement list before navigation. NavBar - // already hides links to non-entitled routes; this guard + // #847 — enterprise entitlement guard. + // Two modes: + // * `meta.requiresEntitlement: ''` — gate on the named + // feature. Used by `/enterprise/sso` etc. + // * `meta.requiresAnyEntitlement: true` — gate on + // `hasAnyEnterprise` (the catalogue landing page is + // reachable whenever any enterprise feature is entitled). + // NavBar already hides links to non-entitled routes; the guard // catches direct URL visits / bookmarks. const entitlement = to.meta.requiresEntitlement - if (entitlement) { + const requireAny = to.meta.requiresAnyEntitlement + if (entitlement || requireAny) { const { useEnterpriseStore } = await import('../stores/enterprise') const enterpriseStore = useEnterpriseStore() await enterpriseStore.loadFeatureFlags() - if (!enterpriseStore.isEntitled(entitlement)) { + if (entitlement && !enterpriseStore.isEntitled(entitlement)) { + // Non-entitled per-feature → bounce to the catalogue if + // the user can see ANY enterprise feature, else dashboard. + next(enterpriseStore.hasAnyEnterprise ? '/enterprise' : '/') + return + } + if (requireAny && !enterpriseStore.hasAnyEnterprise) { next('/') return } diff --git a/src/frontend/src/views/Login.vue b/src/frontend/src/views/Login.vue index 262ced188..a59ba9e78 100644 --- a/src/frontend/src/views/Login.vue +++ b/src/frontend/src/views/Login.vue @@ -111,6 +111,34 @@
+ +
+
+
+ or sign in with +
+
+
+ +
+

+ SSO flow is a PoC stub (issue #847). Use email or Admin login. +

+
+
+
+``` + +`/api/enterprise/sso/providers` is entitlement-gated but **not** +user-gated — the pre-login screen can call it. OSS-only build: the +router isn't mounted → 404 → catch swallows → list empty → section +hidden. + +### 10. Catalogue + SSO admin views (OSS-bundled) + +The Vue files for enterprise pages live in the public repo: + +- [`src/frontend/src/views/enterprise/Index.vue`](../../../src/frontend/src/views/enterprise/Index.vue) — landing with 5 feature cards (SSO Available; SCIM/SIEM/License/Audit "Coming soon") +- [`src/frontend/src/views/enterprise/SSO.vue`](../../../src/frontend/src/views/enterprise/SSO.vue) — provider list + claim-mapping table + session-policy panel + add-provider modal + +They ship in the OSS bundle but are unreachable when not entitled +(nav + route guard hide them). Vue components have no algorithmic +IP — see [`ENTERPRISE_ARCHITECTURE.md`](../../planning/ENTERPRISE_ARCHITECTURE.md#why-backend-only-private-frontend-ships-in-oss) +for the rationale. + +## Private repo (PoC) + +[`Abilityai/trinity-enterprise`](https://github.com/Abilityai/trinity-enterprise) — backend only: + +| File | What | +|---|---| +| [`backend/__init__.py`](https://github.com/Abilityai/trinity-enterprise/blob/main/backend/__init__.py) | `register_enterprise(app)` — single integration entry | +| [`backend/sso/router.py`](https://github.com/Abilityai/trinity-enterprise/blob/main/backend/sso/router.py) | `/api/enterprise/sso/{providers,login/{id},claim-mapping,session-policy}` — all stubs | +| [`backend/sso/providers.py`](https://github.com/Abilityai/trinity-enterprise/blob/main/backend/sso/providers.py) | `SSOProvider` ABC + `StubProvider` for the PoC registry | +| `pyproject.toml`, `LICENSE` (proprietary), `README.md` | metadata + docs | + +The `/api/enterprise/sso/providers` endpoint seeds two mock +providers at module import (Okta-mock + Azure-AD-mock) so the OSS +admin UI demo renders realistic content. Display names carry +`(Mock — PoC)` so operators can't mistake them for working providers. + +## Failure modes + +### OSS-only build (submodule absent) + +``` +docker logs trinity-backend | grep "Trinity Enterprise" +# → "Trinity Enterprise submodule not present — OSS-only build ..." + +GET /api/settings/feature-flags → "enterprise_features": [] +GET /api/enterprise/sso/providers → 404 Not Found (router not mounted) +``` + +Frontend: nav link hidden, login SSO section hidden, direct URL +visits redirect to `/`. Verified manually by `mv src/backend/enterprise{,.disabled}` + force-recreate backend. + +### Hard override (compliance lockdown) + +``` +echo "TRINITY_OSS_ONLY=1" >> .env +docker compose up -d --force-recreate backend + +GET /api/settings/feature-flags → "enterprise_features": [] +GET /api/enterprise/sso/providers → 403 "Enterprise feature 'sso' is not licensed" +``` + +Submodule may be mounted but every enterprise endpoint denies. +Used for: operators who want the OSS UX even with enterprise present +(testing the deny path); CI builds that exercise the gate. + +[`docker-compose.yml`](../../../docker-compose.yml) passes the env +through to the backend container. + +## Adding a new enterprise feature — recipe + +Say you want to ship SCIM provisioning. + +### 1. Private repo (backend logic + IP) +```python +# backend/scim/router.py +from fastapi import APIRouter, Depends +router = APIRouter() +# ... endpoints, gated by Depends(requires_entitlement("scim")) ... + +# backend/__init__.py — extend register_enterprise: +from .scim.router import router as scim_router +app.include_router(scim_router, prefix="/api/enterprise/scim", tags=["enterprise-scim"]) +entitlement_service.register_module("scim") +``` + +### 2. Public repo (frontend UI) +```vue + + +``` + +```js +// src/frontend/src/router/index.js — add route: +{ + path: '/enterprise/scim', + name: 'EnterpriseSCIM', + component: () => import('../views/enterprise/SCIM.vue'), + meta: { requiresAuth: true, requiresEntitlement: 'scim' } +}, + +// src/frontend/src/views/enterprise/Index.vue — flip the card: +{ id: 'scim', ..., soon: false }, // was true +``` + +### 3. Bump submodule pointer in public repo +```bash +cd src/backend/enterprise && git pull +cd ../../../ && git add src/backend/enterprise && git commit -m "chore: bump enterprise submodule (SCIM)" +``` + +No CI changes needed — the build-without-submodule workflow already +asserts the conditional path works for any feature_id. + +## Tests + +[`tests/unit/test_847_entitlement_seam.py`](../../../tests/unit/test_847_entitlement_seam.py) — 13 unit tests + 2 skipped (local-only): + +| Test | Asserts | +|---|---| +| `test_empty_registry_denies_every_feature` | OSS default state — empty list, all denied | +| `test_register_module_then_entitled` | Post-`register_module("sso")`, `is_entitled` True + listed | +| `test_register_module_is_idempotent` | Double-register doesn't grow list | +| `test_oss_only_denies_every_feature_even_when_registered` | Env override beats registry | +| `test_oss_only_falsy_keeps_registry_behaviour` | Falsy spellings (0/false/no/"") keep registry | +| `test_requires_entitlement_allows_when_entitled` | Dependency `Depends()` returns None on allow | +| `test_requires_entitlement_raises_403_when_denied` | Dependency raises HTTP 403 with feature_id in detail | +| `test_set_for_testing_swaps_singleton` | Test seam for injecting custom service | +| `test_main_py_uses_conditional_enterprise_import` | Static check — main.py has try/except ImportError | + +CI also runs [`build-without-submodule.yml`](../../../.github/workflows/build-without-submodule.yml) +which boots the backend with the submodule absent and asserts: + +- `/health` responds +- `/api/settings/feature-flags` returns `enterprise_features: []` +- `/api/enterprise/sso/providers` returns 404 +- "Trinity Enterprise submodule not present" log line emitted + +## Out of scope (open follow-ups) + +| Phase | What | Status | +|---|---|---| +| 1 | Ed25519-signed license token + admin License UI | not started | +| 2 | Extract audit log into the private submodule (first real enterprise module beyond SSO PoC) | not started | +| 3 | Prove "core-primitive + enterprise-knob" pattern via #834 (recovery API in OSS, license-capped retention in enterprise) | not started | +| 4 | Real SSO/SAML/OIDC implementation (replaces PoC stubs) | not started | +| — | MCP entitlement edge (`GET /api/internal/entitlements` polled by the TypeScript MCP server) | not started | +| — | Fix repo license-of-record (currently `NOASSERTION`) | owner decision | + +Tracking issue: [#847](https://github.com/abilityai/trinity/issues/847). From 9845286675a5f633648bb7eef7cc0abee2357e6f Mon Sep 17 00:00:00 2001 From: Oleksii Dolhov Date: Tue, 26 May 2026 15:57:15 +0300 Subject: [PATCH 10/17] feat(audit): enterprise audit log dashboard (#941) + remove SSO PoC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the #847 Phase 0 SSO mock stub with the first concrete enterprise feature: an admin-facing audit log dashboard. Backend (public repo, stays OSS): - New GET /api/audit-log/distinct/event-types - New GET /api/audit-log/distinct/actor-types - Both admin-gated (Depends(require_admin)); populate dashboard filter dropdowns without hardcoding the AuditEventType enum on the frontend. - Registered BEFORE /{event_id} catch-all (invariant #4). Frontend (public OSS bundle, entitlement-gated route): - views/enterprise/Audit.vue — paginated table + filter form + side detail panel. - stores/auditLog.js — domain store (entries, filters, distinct lists, pagination, selectedEntry). Default time window = last 24h. - router/index.js — /enterprise/audit route with meta.requiresEntitlement = 'audit'. - views/enterprise/Index.vue — audit card flipped to Available; SSO card kept as Coming soon. - Login.vue — removed the SSO provider buttons mock. - Deleted views/enterprise/SSO.vue (350-line mock). Submodule (trinity-enterprise): - register_module("audit") replaces register_module("sso"). - Deleted backend/sso/{router,providers,__init__}.py. - See trinity-enterprise#feature/941-audit-registration. Entitlement model: backend stays OSS (audit_log endpoints predate the seam via SEC-001 / #20 — retroactive gating would break OSS admins). Only the OSS-side dashboard route is enterprise-gated. Tests: - New tests/unit/test_847_audit_dashboard.py (6 cases): distinct DB ops, router ordering invariant, admin gate, no-entitlement-gate pin. - Updated test_847_entitlement_seam.py: 'sso' → 'audit' assertions, new submodule static check. - New e2e/audit-dashboard.spec.js (Playwright, 4 cases). Docs: - audit-trail.md — Phase 5 row + Frontend Layer section. - enterprise-modules.md — current-state note + audit registration code. - architecture.md — distinct endpoints added to audit-log table. PR #910 scope expands to close both #847 (seam) and #941 (dashboard). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/memory/architecture.md | 2 + docs/memory/feature-flows/audit-trail.md | 40 +- .../feature-flows/enterprise-modules.md | 27 +- src/backend/database.py | 8 + src/backend/db/audit.py | 28 ++ src/backend/enterprise | 2 +- src/backend/routers/audit_log.py | 23 + src/frontend/e2e/audit-dashboard.spec.js | 123 ++++++ src/frontend/src/router/index.js | 8 +- src/frontend/src/stores/auditLog.js | 202 +++++++++ src/frontend/src/views/Login.vue | 62 --- src/frontend/src/views/enterprise/Audit.vue | 403 ++++++++++++++++++ src/frontend/src/views/enterprise/Index.vue | 9 +- src/frontend/src/views/enterprise/SSO.vue | 350 --------------- tests/unit/test_847_audit_dashboard.py | 258 +++++++++++ tests/unit/test_847_entitlement_seam.py | 77 +++- 16 files changed, 1170 insertions(+), 452 deletions(-) create mode 100644 src/frontend/e2e/audit-dashboard.spec.js create mode 100644 src/frontend/src/stores/auditLog.js create mode 100644 src/frontend/src/views/enterprise/Audit.vue delete mode 100644 src/frontend/src/views/enterprise/SSO.vue create mode 100644 tests/unit/test_847_audit_dashboard.py diff --git a/docs/memory/architecture.md b/docs/memory/architecture.md index 75b04b4fa..e5538a836 100644 --- a/docs/memory/architecture.md +++ b/docs/memory/architecture.md @@ -691,6 +691,8 @@ agent — detects the §P5 silent-clobber setup at fleet level. | GET | `/api/audit-log` | Admin | List entries (filters: event_type, actor_type, actor_id, target_type, target_id, source, start_time, end_time, limit, offset) | | GET | `/api/audit-log/stats` | Admin | Aggregate counts by event_type and actor_type | | GET | `/api/audit-log/{event_id}` | Admin | Single entry by UUID | +| GET | `/api/audit-log/distinct/event-types` | Admin | Sorted unique `event_type` values — populates dashboard filter dropdown (#941) | +| GET | `/api/audit-log/distinct/actor-types` | Admin | Sorted unique `actor_type` values — dashboard filter dropdown (#941) | | GET | `/api/audit-log/export` | Admin | Export time-range entries as `json` or `csv` (Phase 4) | | POST | `/api/audit-log/verify` | Admin | Verify SHA-256 hash chain over `start_id..end_id` (Phase 4) | | POST | `/api/audit-log/hash-chain/enable` | Admin | Toggle hash chain computation for new entries (Phase 4) | diff --git a/docs/memory/feature-flows/audit-trail.md b/docs/memory/feature-flows/audit-trail.md index 2121ffa38..c2a73fc0e 100644 --- a/docs/memory/feature-flows/audit-trail.md +++ b/docs/memory/feature-flows/audit-trail.md @@ -15,7 +15,8 @@ after merge. | **Phase 2a** | Agent lifecycle integration (create / start / stop / delete) as working smoke test | ✅ Merged | | **Phase 2b** | Auth, sharing, credentials, settings, rename integrations + request_id middleware | ✅ This PR | | **Phase 3** | MCP server integration — TypeScript audit logging for all tool calls | ✅ This PR | -| **Phase 4** | Hash chain verification, CSV/JSON export, enable/disable toggle | ✅ This PR | +| **Phase 4** | Hash chain verification, CSV/JSON export, enable/disable toggle | ✅ Merged | +| **Phase 5** | Admin dashboard UI (`/enterprise/audit`) + distinct-value endpoints for filter dropdowns | ✅ #941 | ## User Story As a platform admin, I want a tamper-evident record of every administrative @@ -29,9 +30,40 @@ and trace who modified what across the platform. ## Frontend Layer -None in Phase 1. Operators query via the OpenAPI docs at `/docs` or curl until -a Phase 4+ UI ships. The existing `/api/audit` router (which exposes Process -Engine audit) is unchanged. +Phase 5 (#941) adds an admin-facing dashboard at `/enterprise/audit`. The view +ships in the OSS bundle; the route is gated by `requiresEntitlement: 'audit'` +in `src/frontend/src/router/index.js`, so OSS-only deploys (no enterprise +submodule mounted) bounce to the dashboard catalogue or home. Backend +endpoints stay OSS — only the dashboard UI is enterprise-flagged. + +| File | Role | +|---|---| +| `src/frontend/src/views/enterprise/Audit.vue` | Dashboard view — filter form + paginated table + side detail panel | +| `src/frontend/src/stores/auditLog.js` | Pinia store — entries, filters (default last 24h), pagination, distinct lists, selected entry | +| `src/frontend/src/views/enterprise/Index.vue` | Enterprise landing — audit card flipped from `soon: true` to Available | +| `src/frontend/src/router/index.js` | Route gate (`requiresEntitlement: 'audit'`) | +| `src/backend/enterprise/backend/__init__.py` | Submodule entitlement registration (`register_module("audit")`) — flips the UI route from hidden to visible | + +### Distinct-value endpoints (#941) + +Two cheap aggregate endpoints feed the dashboard's filter dropdowns so the +frontend doesn't hardcode the `event_type` / `actor_type` enums: + +- `GET /api/audit-log/distinct/event-types` → sorted `list[str]` +- `GET /api/audit-log/distinct/actor-types` → sorted `list[str]` + +Both admin-only (`Depends(require_admin)`). Indexed source columns, low +cardinality — sub-ms even on a million-row audit_log. + +### Out of scope for Phase 5 + +These were deferred to follow-up issues — backend support already exists: +CSV/JSON export download button, hash-chain verify button, stats tiles +on the dashboard header, SIEM webhook push (#847 enterprise pillar). + +Note: the legacy `/api/audit` router (Process Engine audit) was removed in +#430 (2026-04-24). The platform audit log at `/api/audit-log` is the only +audit surface now. ## Backend Layer diff --git a/docs/memory/feature-flows/enterprise-modules.md b/docs/memory/feature-flows/enterprise-modules.md index 942ed0df9..baa953a92 100644 --- a/docs/memory/feature-flows/enterprise-modules.md +++ b/docs/memory/feature-flows/enterprise-modules.md @@ -8,6 +8,25 @@ Companion to: - [`docs/planning/OSS_ENTERPRISE_SPLIT_RESEARCH.md`](../../planning/OSS_ENTERPRISE_SPLIT_RESEARCH.md) — long-form research - [`docs/dev/ENTERPRISE_LOCAL_DEV.md`](../../dev/ENTERPRISE_LOCAL_DEV.md) — 15-min clone-to-running guide +## Current state (#910 + #941) + +**What ships today:** the entitlement seam + the audit log dashboard. +The original #847 Phase 0 PoC mounted a `/api/enterprise/sso/*` router +with mock OIDC/SAML providers; that scaffold was removed in #910 scope +expansion (which now closes both #847 and #941). SSO returns later with +a real implementation, not a stub. + +Currently registered enterprise feature: **`audit`**. The audit log +dashboard at `/enterprise/audit` is the first concrete enterprise UI; +its backend endpoints stay OSS in `routers/audit_log.py` (the +entitlement only flips the OSS-side dashboard ROUTE from hidden to +visible). See [`audit-trail.md`](audit-trail.md) for the dashboard +feature flow. + +Historical SSO snippets below are kept for reference on the seam +pattern itself — the mechanism (try/except + `register_module()` + +`enterprise_features` flag) is unchanged. + ## Topology ``` @@ -92,10 +111,10 @@ def register_enterprise(app) -> None: from services.entitlement_service import entitlement_service - # SSO (#847 PoC) — stub endpoints only - from .sso.router import router as sso_router - app.include_router(sso_router, prefix="/api/enterprise/sso", tags=["enterprise-sso"]) - entitlement_service.register_module("sso") + # Audit log dashboard (#941) — entitlement flips the OSS-side + # dashboard route from hidden to visible. Endpoints live in the + # public repo (`routers/audit_log.py`); no router mount needed here. + entitlement_service.register_module("audit") app.state.enterprise_registered = True ``` diff --git a/src/backend/database.py b/src/backend/database.py index fc4ea55de..ffb5750ed 100644 --- a/src/backend/database.py +++ b/src/backend/database.py @@ -1963,6 +1963,14 @@ def get_audit_stats(self, start_time: str = None, end_time: str = None): """Aggregate counts by event_type and actor_type for the dashboard.""" return self._audit_ops.get_audit_stats(start_time=start_time, end_time=end_time) + def get_distinct_event_types(self): + """Distinct event_type values across audit_log (sorted).""" + return self._audit_ops.get_distinct_event_types() + + def get_distinct_actor_types(self): + """Distinct actor_type values across audit_log (sorted).""" + return self._audit_ops.get_distinct_actor_types() + def prune_audit_log(self, retention_days: int) -> int: """Delete audit_log entries older than ``retention_days``. Returns count removed.""" return self._audit_ops.prune_audit_log(retention_days) diff --git a/src/backend/db/audit.py b/src/backend/db/audit.py index 5de93c926..9b6d09886 100644 --- a/src/backend/db/audit.py +++ b/src/backend/db/audit.py @@ -202,6 +202,34 @@ def get_audit_entries_range(self, start_id: int, end_id: int) -> List[Dict[str, ) return [self._row_to_dict(row) for row in cursor.fetchall()] + def get_distinct_event_types(self) -> List[str]: + """Return sorted unique event_type values across the audit log. + + Used by the dashboard (#941) to populate filter dropdowns without + hardcoding the enum. Indexed column + low cardinality → cheap. + """ + with get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT DISTINCT event_type FROM audit_log " + "WHERE event_type IS NOT NULL ORDER BY event_type" + ) + return [row[0] for row in cursor.fetchall()] + + def get_distinct_actor_types(self) -> List[str]: + """Return sorted unique actor_type values across the audit log. + + Companion to get_distinct_event_types — drives the actor_type + dropdown on the audit dashboard. + """ + with get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT DISTINCT actor_type FROM audit_log " + "WHERE actor_type IS NOT NULL ORDER BY actor_type" + ) + return [row[0] for row in cursor.fetchall()] + def get_audit_stats( self, start_time: Optional[str] = None, diff --git a/src/backend/enterprise b/src/backend/enterprise index 3e90ddc73..1e916f604 160000 --- a/src/backend/enterprise +++ b/src/backend/enterprise @@ -1 +1 @@ -Subproject commit 3e90ddc732888caebb55101db8d8479b99f717e7 +Subproject commit 1e916f60483ad2a11121ce997a0b9efa609477c1 diff --git a/src/backend/routers/audit_log.py b/src/backend/routers/audit_log.py index acc4073ba..8c85d9397 100644 --- a/src/backend/routers/audit_log.py +++ b/src/backend/routers/audit_log.py @@ -227,6 +227,29 @@ async def list_audit_log( ) +# --------------------------------------------------------------------------- +# Distinct-value endpoints (#941) — populate dashboard filter dropdowns +# without hardcoding the enum on the frontend. MUST stay above the +# `/{event_id}` catch-all (invariant #4: static routes before parametrised). +# --------------------------------------------------------------------------- + + +@router.get("/distinct/event-types", response_model=List[str]) +async def list_distinct_event_types( + _admin: User = Depends(require_admin), +): + """Return sorted unique event_type values present in the audit log.""" + return db.get_distinct_event_types() + + +@router.get("/distinct/actor-types", response_model=List[str]) +async def list_distinct_actor_types( + _admin: User = Depends(require_admin), +): + """Return sorted unique actor_type values present in the audit log.""" + return db.get_distinct_actor_types() + + @router.get("/{event_id}", response_model=AuditLogEntry) async def get_audit_log_entry( event_id: str, diff --git a/src/frontend/e2e/audit-dashboard.spec.js b/src/frontend/e2e/audit-dashboard.spec.js new file mode 100644 index 000000000..d276bde02 --- /dev/null +++ b/src/frontend/e2e/audit-dashboard.spec.js @@ -0,0 +1,123 @@ +import { test, expect } from '@playwright/test' + +/** + * Enterprise audit log dashboard e2e (#941). + * + * Drives the new /enterprise/audit dashboard end-to-end: + * - the Enterprise nav link appears for admins (any entitlement) + * - the /enterprise landing surfaces an "Audit Log" card marked Available + * - clicking the card navigates to /enterprise/audit + * - the dashboard renders the filter form + table + * - filtering by event_type changes the visible rows + * - clicking a row opens the side detail panel + * + * Assumes the enterprise submodule is mounted (default for the local + * stack and the frontend-e2e workflow). In OSS-only builds the + * /enterprise route 302s to / — that path is covered by the unit + * test on the route guard, not this spec. + * + * The admin login itself emits an `authentication` audit event the + * first time we authenticate, so by the time we reach the dashboard + * there is at least one row to render. We do NOT seed extra rows via + * the internal write endpoint — keeping the spec read-only avoids + * polluting the dev audit log with synthetic noise. + */ + +test.describe('enterprise audit dashboard (#941)', () => { + test('@smoke admin sees Enterprise nav and the audit card', async ({ page }) => { + await page.goto('/') + // NavBar lazily fires the feature-flags request on mount; give it + // a beat to resolve before we assert. + const enterpriseNav = page.locator('nav a:has-text("Enterprise")') + await expect(enterpriseNav).toBeVisible({ timeout: 10000 }) + + await enterpriseNav.click() + await expect(page).toHaveURL(/\/enterprise$/) + + // The audit card is now Available (#941 flips its soon flag). + const auditCard = page.locator('h3:has-text("Audit Log")') + await expect(auditCard).toBeVisible() + // Confirm the card is in the Available state (not Coming soon). + const card = auditCard.locator('xpath=ancestor::*[contains(@class,"block")][1]') + await expect(card.locator('text=Available').first()).toBeVisible() + }) + + test('@smoke clicking the audit card opens the dashboard', async ({ page }) => { + await page.goto('/enterprise') + await page.locator('h3:has-text("Audit Log")').click() + await expect(page).toHaveURL(/\/enterprise\/audit$/) + + // Header + filter form render. + await expect(page.locator('h1:has-text("Audit Log")')).toBeVisible() + await expect(page.locator('text=Filters')).toBeVisible() + await expect(page.locator('button:has-text("Apply")')).toBeVisible() + await expect(page.locator('button:has-text("Reset")')).toBeVisible() + + // Either the table OR the empty state must render (depends on + // whether the local DB has any rows in the last-24h window). + const hasTable = await page + .locator('table') + .first() + .isVisible({ timeout: 5000 }) + .catch(() => false) + const hasEmptyState = await page + .locator('text=No audit entries match these filters.') + .isVisible({ timeout: 5000 }) + .catch(() => false) + expect(hasTable || hasEmptyState).toBeTruthy() + }) + + test('@smoke row click opens the side detail panel', async ({ page }) => { + await page.goto('/enterprise/audit') + + // Widen the time window so we're not flaky on dev instances where + // the last-24h slice is empty (e.g. the box was off). + await page.locator('input[placeholder*="2026-"]').first().fill('') + await page.locator('button:has-text("Apply")').click() + + // If still empty, the rest of the test isn't meaningful — skip + // rather than fail. Catches the genuinely-empty audit_log case. + const emptyVisible = await page + .locator('text=No audit entries match these filters.') + .isVisible({ timeout: 5000 }) + .catch(() => false) + test.skip(emptyVisible, 'audit_log is empty on this instance') + + // Click the first data row. + const firstRow = page.locator('tbody tr').first() + await expect(firstRow).toBeVisible({ timeout: 10000 }) + await firstRow.click() + + // Side panel renders with at least the event_type/action header + // and the details JSON disclosure. + await expect(page.locator('text=Details JSON')).toBeVisible({ timeout: 5000 }) + await expect(page.locator('text=Hash chain')).toBeVisible() + }) + + test('@smoke filter dropdown is populated from the distinct endpoint', async ({ page }) => { + await page.goto('/enterprise/audit') + + // The event_type should now hold the value we clicked. + const eventTypeSelect = page.locator('label:has-text("Event type") + select') + await expect(eventTypeSelect).toHaveValue(eventTypeValue, { timeout: 5000 }) + + // The active-preset chip should flip to Custom (drill-down sets + // activePreset = 'custom'). + await expect(page.locator('text=/^Custom$/')).toBeVisible() + }) }) diff --git a/src/frontend/src/stores/auditLog.js b/src/frontend/src/stores/auditLog.js index 1d80aa9e8..d965d9239 100644 --- a/src/frontend/src/stores/auditLog.js +++ b/src/frontend/src/stores/auditLog.js @@ -8,10 +8,13 @@ * lookup don't get tangled. * * Endpoints consumed (all admin-only, public repo): - * GET /api/audit-log — filtered + paginated list - * GET /api/audit-log/{event_id} — single entry detail - * GET /api/audit-log/distinct/event-types — filter dropdown values - * GET /api/audit-log/distinct/actor-types — filter dropdown values + * GET /api/audit-log — filtered + paginated list + * GET /api/audit-log/{event_id} — single entry detail + * GET /api/audit-log/distinct/event-types — filter dropdown values + * GET /api/audit-log/distinct/actor-types — filter dropdown values + * GET /api/audit-log/stats — aggregate counts (#941 v2) + * POST /api/audit-log/verify — hash chain integrity (#941 v2) + * GET /api/audit-log/export — CSV/JSON download (#941 v2) * * Default time window: last 24h (per #941 acceptance criteria). * @@ -55,6 +58,13 @@ export const useAuditLogStore = defineStore('auditLog', { loading: false, detailLoading: false, error: '', + // #941 v2 — dashboard expansion + stats: null, // { total, by_event_type: {...}, by_actor_type: {...} } + statsLoading: false, + verifyState: 'idle', // idle | verifying | valid | invalid | error + verifyResult: null, // { checked, first_invalid_id?, range?: [start, end] } + activePreset: '24h', // '1h' | '24h' | '7d' | '30d' | 'all' | 'custom' + exporting: false, }), getters: { @@ -69,6 +79,41 @@ export const useAuditLogStore = defineStore('auditLog', { const end = Math.min(state.offset + state.entries.length, state.total) return `Showing ${start}–${end} of ${state.total}` }, + // #941 v2 — top entry from the stats aggregate, used by the + // header tiles. Returns null when stats isn't loaded or empty. + topEventType: (state) => { + const m = state.stats?.by_event_type + if (!m) return null + const entries = Object.entries(m) + if (entries.length === 0) return null + const [key, count] = entries.reduce((best, cur) => + cur[1] > best[1] ? cur : best + ) + return { key, count } + }, + topActorType: (state) => { + const m = state.stats?.by_actor_type + if (!m) return null + const entries = Object.entries(m) + if (entries.length === 0) return null + const [key, count] = entries.reduce((best, cur) => + cur[1] > best[1] ? cur : best + ) + return { key, count } + }, + timeWindowLabel: (state) => { + const f = state.filters + if (!f.start_time && !f.end_time) return 'All time' + const start = f.start_time + ? new Date(f.start_time).toISOString().replace('T', ' ').slice(0, 16) + + ' UTC' + : '—' + const end = f.end_time + ? new Date(f.end_time).toISOString().replace('T', ' ').slice(0, 16) + + ' UTC' + : 'now' + return `${start} → ${end}` + }, }, actions: { @@ -176,6 +221,7 @@ export const useAuditLogStore = defineStore('auditLog', { this.filters = emptyFilters() this.offset = 0 this.selectedEntry = null + this.activePreset = '24h' }, setPage(page) { @@ -198,5 +244,166 @@ export const useAuditLogStore = defineStore('auditLog', { clearSelection() { this.selectedEntry = null }, + + // ───────────────────────────────────────────────────────────────────── + // #941 v2 — dashboard expansion + // ───────────────────────────────────────────────────────────────────── + + /** Reload the stats aggregate over the current time window. */ + async loadStats() { + const authStore = useAuthStore() + if (!authStore.isAuthenticated) return + this.statsLoading = true + try { + const params = {} + if (this.filters.start_time) params.start_time = this.filters.start_time + if (this.filters.end_time) params.end_time = this.filters.end_time + const r = await axios.get('/api/audit-log/stats', { + headers: authStore.authHeader, + params, + }) + this.stats = r.data || null + } catch (e) { + // Stats failure shouldn't blow up the dashboard — keep stale data + // or null. Pin the error so the tiles can render "—". + this.stats = null + } finally { + this.statsLoading = false + } + }, + + /** + * Set a relative time window and reload list + stats. + * @param {string} key one of '1h' | '24h' | '7d' | '30d' | 'all' + */ + async applyTimePreset(key) { + const hoursByKey = { '1h': 1, '24h': 24, '7d': 24 * 7, '30d': 24 * 30 } + if (key === 'all') { + this.filters.start_time = '' + this.filters.end_time = '' + } else if (hoursByKey[key]) { + this.filters.start_time = isoMinusHours(hoursByKey[key]) + this.filters.end_time = '' + } else { + return + } + this.activePreset = key + this.offset = 0 + await Promise.all([this.loadList(), this.loadStats()]) + }, + + /** + * Drill-down click handler. Sets a single filter, resets paging, and + * reloads list + stats. Leaves other filters intact (P3 — preserves + * the user's filter context). + */ + async drilldownFilter(key, value) { + if (!(key in this.filters)) return + this.filters[key] = value || '' + this.offset = 0 + this.activePreset = 'custom' + await Promise.all([this.loadList(), this.loadStats()]) + }, + + /** + * Verify the hash chain over the currently-visible id range. + * + * Visible-id-range only — full-DB verify is for a compliance audit, + * not dashboard load. The verify endpoint takes integer id bounds, + * not event_id UUIDs, so we read `id` from the in-list rows. If the + * range is empty (no entries), the verify is a trivial "valid 0". + */ + async verifyChain() { + const authStore = useAuthStore() + if (!authStore.isAuthenticated) return + if (this.entries.length === 0) { + this.verifyState = 'valid' + this.verifyResult = { checked: 0, range: null } + return + } + const ids = this.entries.map((e) => Number(e.id)).filter((n) => !isNaN(n)) + if (ids.length === 0) { + this.verifyState = 'error' + this.verifyResult = null + return + } + const startId = Math.min(...ids) + const endId = Math.max(...ids) + this.verifyState = 'verifying' + try { + const r = await axios.post( + '/api/audit-log/verify', + null, + { + headers: authStore.authHeader, + params: { start_id: startId, end_id: endId }, + } + ) + const data = r.data || {} + this.verifyResult = { + checked: Number(data.checked) || 0, + first_invalid_id: data.first_invalid_id ?? null, + range: [startId, endId], + } + this.verifyState = data.valid ? 'valid' : 'invalid' + } catch (e) { + this.verifyState = 'error' + this.verifyResult = null + this.error = + e?.response?.data?.detail || + e?.message || + 'Failed to verify hash chain' + } + }, + + /** + * Download the current filter view as CSV or JSON. + * + * Uses fetch + Blob + object-URL so we can attach the JWT header + * (a plain can't carry an Authorization header). Backend + * endpoint /api/audit-log/export requires start_time + end_time, so + * we coerce the current filter — falling back to "last 24h → now" + * if either bound is empty. + */ + async downloadExport(format = 'json') { + const authStore = useAuthStore() + if (!authStore.isAuthenticated) return + if (!['csv', 'json'].includes(format)) return + this.exporting = true + try { + const params = { format } + params.start_time = this.filters.start_time || isoMinusHours(24) + params.end_time = this.filters.end_time || new Date().toISOString() + const r = await axios.get('/api/audit-log/export', { + headers: authStore.authHeader, + params, + responseType: format === 'csv' ? 'blob' : 'json', + }) + + // Build a Blob whether the response is text/csv or application/json. + const blob = + format === 'csv' + ? r.data + : new Blob([JSON.stringify(r.data, null, 2)], { + type: 'application/json', + }) + + const ts = new Date().toISOString().replace(/[:.]/g, '-') + const filename = `audit-log-${ts}.${format}` + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + a.remove() + window.URL.revokeObjectURL(url) + } catch (e) { + this.error = + e?.response?.data?.detail || e?.message || 'Export failed' + } finally { + this.exporting = false + } + }, }, }) diff --git a/src/frontend/src/views/enterprise/Audit.vue b/src/frontend/src/views/enterprise/Audit.vue index 938358ff3..db54e9156 100644 --- a/src/frontend/src/views/enterprise/Audit.vue +++ b/src/frontend/src/views/enterprise/Audit.vue @@ -1,16 +1,18 @@