Skip to content

feat(enterprise): private submodule + EntitlementService seam + SSO PoC (#847 Phase 0)#910

Draft
dolho wants to merge 7 commits into
devfrom
feature/847-enterprise-sso-poc
Draft

feat(enterprise): private submodule + EntitlementService seam + SSO PoC (#847 Phase 0)#910
dolho wants to merge 7 commits into
devfrom
feature/847-enterprise-sso-poc

Conversation

@dolho
Copy link
Copy Markdown
Contributor

@dolho dolho commented May 21, 2026

Summary

Closes issue #847 (spike). Establishes the open-core architecture: public Trinity + private companion repo Abilityai/trinity-enterprise mounted as a submodule at src/backend/enterprise/. Loads conditionally so OSS-only builds (no submodule access) work unchanged.

What lands

Public-repo seam

  • services/entitlement_service.pyEntitlementService Phase 0 stub. All-entitled by default; TRINITY_OSS_ONLY=1 env flips every check to False.
  • dependencies.py:requires_entitlement(feature_id) — FastAPI dependency mirroring require_role; raises HTTP 403 naming the missing feature.
  • main.py — conditional try: from enterprise import register_enterprise except ImportError. Idempotent.
  • /api/settings/feature-flags extended with enterprise_features: list[str].
  • .gitmodules entry for the private repo.
  • docker-compose.yml pass-through for TRINITY_OSS_ONLY.
  • New CI workflow build-without-submodule.yml asserts OSS-only boot works.

Private-repo PoC (Abilityai/trinity-enterprise, mounted at src/backend/enterprise/)

  • __init__.py exporting register_enterprise(app)
  • sso/router.py/api/enterprise/sso/{providers,login/{id}} stubs, gated by requires_entitlement("sso")
  • sso/providers.pySSOProvider ABC + StubProvider

Teststests/unit/test_847_entitlement_seam.py (14 cases)

Docs

  • docs/planning/ENTERPRISE_ARCHITECTURE.md — condensed decision record
  • docs/planning/OSS_ENTERPRISE_SPLIT_RESEARCH.md — 521-line research
  • docs/dev/ENTERPRISE_LOCAL_DEV.md — 15-min clone-to-running guide

Live verification (local)

Check Default mode TRINITY_OSS_ONLY=1
GET /api/enterprise/sso/providers 200 [] 403 "Enterprise feature 'sso' is not licensed"
GET /api/settings/feature-flags enterprise_features ["sso", "scim", "siem"] []
POST /api/enterprise/sso/login/foo 404 unknown provider 403 entitlement

Round-trip restoration confirmed.

Out of scope (follow-up issues to file)

  • Phase 1: Ed25519-signed license token + verify path + admin License UI
  • Phase 2: extract audit_log into the submodule
  • Phase 3: prove "core-primitive + enterprise-knob" via Soft-delete all entities + configurable data retention policy #834
  • Phase 4: real SSO/SAML implementation
  • MCP entitlement edge for the TS MCP server
  • License-of-record fix (currently NOASSERTION — owner decision)

Related to #847

🤖 Generated with Claude Code

@dolho dolho requested a review from AndriiPasternak31 as a code owner May 21, 2026 12:44
@dolho dolho marked this pull request as draft May 21, 2026 12:45
dolho and others added 2 commits May 22, 2026 12:32
…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>
@dolho dolho force-pushed the feature/847-enterprise-sso-poc branch from 09b845f to cdf783d Compare May 22, 2026 09:32
dolho and others added 5 commits May 22, 2026 12:55
)

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant