feat(enterprise): private submodule + EntitlementService seam + SSO PoC (#847 Phase 0)#910
Draft
dolho wants to merge 7 commits into
Draft
feat(enterprise): private submodule + EntitlementService seam + SSO PoC (#847 Phase 0)#910dolho wants to merge 7 commits into
dolho wants to merge 7 commits into
Conversation
…oC (#847 Phase 0) 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) <noreply@anthropic.com>
…e 0.5)
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) <noreply@anthropic.com>
09b845f to
cdf783d
Compare
) 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) <noreply@anthropic.com>
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: '<id>'` 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) <noreply@anthropic.com>
Two CI issues from the previous push:
1. `lint (sys.modules pollution check)` — 6 bare
`del sys.modules[...]` calls in `test_847_entitlement_seam.py`
exceeded baseline (`tests/lint_sys_modules.py` requires either
`monkeypatch.delitem(sys.modules, ..., raising=False)` or the
sanctioned `_STUBBED_MODULE_NAMES` pattern).
Replaced all six with `monkeypatch.delitem` so the auto-restore
on teardown also stops the test from leaking stale module
imports into sibling tests. `_import_requires_entitlement_or_skip`
helper now takes `monkeypatch` as a parameter so the pattern
works from inside the helper too.
2. `backend boots without enterprise submodule` — `/api/token`
returned 403 on a fresh DB because `is_setup_completed()` (in
`routers/auth.py:212`) gates admin login behind first-time
setup, which the previous workflow didn't complete. Added a new
"Complete first-time setup" step that:
- greps the setup token from `docker logs trinity-backend` (the
token is printed to stdout at boot per `main.py:336-343`,
gated on `setup_completed != true`)
- POSTs to `/api/setup/admin-password` with the
setup_token + password + confirm_password fields (schema in
`routers/setup.py:28-32`)
Replaced the hex `ADMIN_PASSWORD` with a known-strong value that
meets the OWASP ASVS 2.1 complexity check the setup endpoint
enforces (length 12+, upper, lower, digit, special).
Also dropped the Bearer auth from the "SSO router not mounted"
assertion — the `/api/enterprise/sso/*` router is
entitlement-gated, not user-gated, so the unauthenticated 404
check still proves the conditional import correctly skipped the
mount.
Tests: 13 passed + 2 skipped locally (no-passlib).
Lint: clean against baseline.
Related to #847
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The print() in main.py:336-343 that emits the setup token to stdout is Python-block-buffered when stdout is a pipe (docker logs capture), so the grep on a short timing window after /health returns OK is unreliable. The previous attempt extracted an empty SETUP_TOKEN and failed. Bypass the API entirely: docker exec into the backend container and write directly to the DB using the same helpers the production endpoint uses (dependencies.hash_password + db.set_setting + db.update_user_password). The flow: - set system_settings.setup_completed = 'true' - hash ADMIN_PASSWORD via the production helper - update admin user's password_hash The password is passed via docker -e to avoid shell quoting issues with the OWASP-compliant value (contains !). Verified locally: docker exec into trinity-backend, the python snippet runs cleanly and the bcrypt warning visible in CI logs is benign (passlib version mismatch, doesn't break hashing). Related to #847
The enterprise conditional-import block in main.py runs at module init, which is BEFORE `lifespan` calls `setup_logging()`. Default Python logging is at WARNING level, so `logger.info(...)` records are silently dropped — neither the registered nor the OSS-only log line appeared in docker logs, breaking the CI workflow that greps for them. Switch to `print(..., flush=True)` so the output goes to stdout regardless of logger state. docker logs captures it (operators see the boot mode), and the build-without-submodule CI workflow's grep succeeds. Verified locally: `docker logs trinity-backend | grep 'Trinity Enterprise'` now shows the registered/OSS-only line. Related to #847
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes issue #847 (spike). Establishes the open-core architecture: public Trinity + private companion repo
Abilityai/trinity-enterprisemounted as a submodule atsrc/backend/enterprise/. Loads conditionally so OSS-only builds (no submodule access) work unchanged.What lands
Public-repo seam
services/entitlement_service.py—EntitlementServicePhase 0 stub. All-entitled by default;TRINITY_OSS_ONLY=1env flips every check to False.dependencies.py:requires_entitlement(feature_id)— FastAPI dependency mirroringrequire_role; raises HTTP 403 naming the missing feature.main.py— conditionaltry: from enterprise import register_enterprise except ImportError. Idempotent./api/settings/feature-flagsextended withenterprise_features: list[str]..gitmodulesentry for the private repo.docker-compose.ymlpass-through forTRINITY_OSS_ONLY.build-without-submodule.ymlasserts OSS-only boot works.Private-repo PoC (
Abilityai/trinity-enterprise, mounted atsrc/backend/enterprise/)__init__.pyexportingregister_enterprise(app)sso/router.py—/api/enterprise/sso/{providers,login/{id}}stubs, gated byrequires_entitlement("sso")sso/providers.py—SSOProviderABC +StubProviderTests —
tests/unit/test_847_entitlement_seam.py(14 cases)Docs
docs/planning/ENTERPRISE_ARCHITECTURE.md— condensed decision recorddocs/planning/OSS_ENTERPRISE_SPLIT_RESEARCH.md— 521-line researchdocs/dev/ENTERPRISE_LOCAL_DEV.md— 15-min clone-to-running guideLive verification (local)
TRINITY_OSS_ONLY=1GET /api/enterprise/sso/providers[]GET /api/settings/feature-flagsenterprise_features["sso", "scim", "siem"][]POST /api/enterprise/sso/login/fooRound-trip restoration confirmed.
Out of scope (follow-up issues to file)
audit_loginto the submoduleNOASSERTION— owner decision)Related to #847
🤖 Generated with Claude Code