Skip to content

Multi-Tenancy

Logrite uses a workspace-based multi-tenancy model where each organization has an isolated workspace. All data — scans, logs, agents, conversations — is scoped to a workspace.

Every API request carries a JWT that includes workspaceId. The Backend API filters all queries by workspace:

// All endpoints enforce workspace isolation
@GetMapping("/scans")
public List<Scan> getScans(@AuthenticatedWorkspace String workspaceId) {
return scanRepository.findByWorkspaceId(workspaceId);
}

Every tenant index is named <family>-<workspaceId>, with a per-agent variant log-analytics-<workspaceId>-<key>. The families (see com.logwrite.util.IndexNames) are:

log-analytics-<workspaceId>[-<key>] # collector-agent + uploaded log documents
trace-analytics-raw-<workspaceId> # traces / spans
log-cloud-platform-<workspaceId> # AWS/Netlify/Vercel logs
log-observability-<workspaceId> # logs pulled from a customer's own vendor
agent-flags-<workspaceId> # compliance flags
analytics-state-<workspaceId> # saved Analytics UI state
warden-requests-<workspaceId> # governed LLM requests
relay-compliance-events-<workspaceId> # relay compliance events
cortex-traces-<workspaceId> # Cortex turns — per USER, see below (2026-10-14)

For example, workspace 3103 uses log-analytics-3103-<...>. The shared, world-readable sample indices payment_queries, external_service_calls, database_queries, exceptions (and their _semantic variants) carry NO per-tenant data.

Enforcement is not by name alone. All OpenSearch access goes through the backend proxy (/api/v1/opensearch/**), whose OpenSearchIndexAuthorizer resolves every index a request would read — from the path AND from _bulk/_msearch/_mget/_reindex/_sql bodies — and requires each to belong to the caller’s JWT workspace (opensearch.proxy.enforce-jwt=true); cross-workspace or over-broad wildcard targets get 403. As of the 2026-09-08 security remediation the semantic-search API and the MCP server enforce the same per-workspace scoping (both were previously unauthenticated), and the JWT signing key is a per-environment secret (SECURITY_JWT_SECRET_KEY), no longer the committed placeholder.

The family list is duplicated: com.logwrite.util.IndexNames.WS_FAMILIES in the backend and config.WS_FAMILIES in the MCP server. A one-sided edit is silent, so the two move together.

cortex-traces-<workspaceId> holds every member’s Cortex turns, and a member may read only their own. It joined WS_FAMILIES on both sides on 2026-10-14 (so MCP’s analytics_query can read it), and because index authorisation stops at the workspace, the proxy narrows further in CortexTracesScope, after authorisation and before forwarding:

  • It judges every index the request names — the path, _msearch/_mget/_bulk bodies, SQL/PPL FROM, query-DSL lookups — through OpenSearchIndexAuthorizer.extractIndexTokens. A path-only check would leave root /_msearch, /_mget and _plugins/_sql FROM cortex-traces-… reading every member’s turns.
  • The only shape allowed to reach the family is an index-first <target>/_search or /_count whose only route to it is the path. Its query is rewritten to {"bool":{"must":[<original or match_all>],"filter":[<not a cortex-traces index OR userId = caller>]}}, keeping every other top-level key (aggs, size, sort, _source, …). The caller is OwnershipService.currentUser, so an API key resolves to its minter. Hits from other indices in the same search are untouched.
  • Everything else that reaches it is 403 {"error":"cortex_traces_search_only"} — no other shape can carry the filter.
  • A pattern whose literal prefix rules the family out (log-analytics-3103-*) does not reach it and is forwarded byte for byte. _mapping and _cat name no data and stay readable.

The semantic-search API is deliberately excluded: cortex-traces is not in its tenantScope.ts, because semantic search cannot apply the per-user filter.

OpenSearch objects by id: the ownership registry (2026-10-15)

Section titled “OpenSearch objects by id: the ownership registry (2026-10-15)”

Monitors, anomaly detectors and ISM policies are addressed by id, not by index, so the index gates above never saw them: any workspace could read, run, stop or delete another’s by id through the proxy. OpenSearchObjectOwnershipService (wired into ProxyGates.refuse after the notification gate) now guards:

  • /_plugins/_alerting/monitors/{id} and its /_execute and /_acknowledge/alerts
  • /_plugins/_anomaly_detection/detectors/{id} and its /_start, /_stop, /_profile, /_preview (also the detector start/stop in DashboardsTenantAuthorizer)
  • /_plugins/_ism/policies/{id}

The registry is the table opensearch_object_ownership(object_type, object_id, workspace_id, created_at), primary key (object_type, object_id), types monitor / detector / ism_policy. A 2xx create through the proxy records the owner (the response _id for monitors and detectors, the path id for a new ISM policy); a delete removes the row.

A by-id call is allowed when the registry owner is the caller’s workspace; or, with no row, when the object is strictly owned — it names at least one index and every one belongs to the workspace (or is the workspace’s own wildcard); or, for an ISM PUT only, when the id does not exist upstream (a create). Otherwise 403 {"error":"forbidden","reason":"cross-workspace access denied"}. A blank workspace always denies — here and, since the same day, in NotificationOwnershipService. ISM policy bodies are checked too: every policy.ism_template[].index_patterns entry must be strictly owned. Listings (SEARCH_HITS for monitors and detectors, ISM_POLICIES) keep only registry-owned or untracked-and-strictly-owned objects, so cluster-global wildcard objects (agent-flags-* monitors, the CLI *-retention ISM policies) are visible to no workspace.

Conversations in the AI Service are tied to conversationId which includes the workspace context. The Backend API enforces that users can only access conversations from their workspace.

Agents (see AI Agents) are owned by a workspace. An agent created in workspace A cannot access data from workspace B. Agent execution logs are also workspace-scoped.

sequenceDiagram
  participant User
  participant Frontend
  participant Backend
  participant Services

  User->>Frontend: Login (email + password)
  Frontend->>Backend: POST /api/v1/auth/login
  Backend-->>Frontend: JWT {userId, workspaceId, role}
  Frontend->>Frontend: Store JWT in Redux

  Frontend->>Backend: Any request + Authorization: Bearer {JWT}
  Backend->>Backend: Validate JWT, extract workspaceId
  Backend-->>Frontend: Workspace-scoped response

  Frontend->>Services: AI Service, Semantic Search (with workspaceId header)
RolePermissions
AdminFull workspace control — manage users, agents, integrations
AnalystRead/write scans, conversations, log queries
ViewerRead-only access to scans and dashboards

Agent-specific permissions extend this model — see Agent Multi-Tenancy.

Per-workspace rate limits will be enforced at the API Gateway level:

TierScans/dayAI Queries/hourAgents
Free101002
Pro1001,00020
EnterpriseUnlimitedUnlimitedUnlimited

The relay + collector-agent audit closed several tenant-isolation gaps:

  • OpenSearch proxy — the per-workspace OpenSearchIndexAuthorizer now enforces (403s cross-workspace index access) by default (opensearch.proxy.enforce-jwt → true; set OPENSEARCH_PROXY_ENFORCE_JWT=true in prod to activate the cutover). Previously it ran in shadow mode (logged would_deny, still forwarded), so any authenticated user could read another workspace’s data through the client-supplied index path.
  • Fail-closed workspace resolution — a JWT without a workspaceId claim and no unambiguous single workspace is rejected (401) instead of silently resolving to the shared logrite tenant, which had merged distinct principals into one global tenant.
  • Warden control plane — removed the warden-requests-* cross-tenant wildcard fallback; provider-credential / relay-key secrets bind to their workspace via GCM AAD.
  • Collector agents — agent key is unique per-workspace (not globally); host-inventory assets are namespaced by workspace.

Workspace lifecycle: what deleting an account leaves behind

Section titled “Workspace lifecycle: what deleting an account leaves behind”

Membership is the only route into a workspace. There is no owner column on workspaces — ownership is the workspace_user join plus a row in workspace_member_roles — which is what makes the scoping in the sections above work, and also what makes an empty workspace a problem rather than a loose end.

AccountDeletionService detaches the departing user (membership + roles) and nulls their home workspace_id. It used to stop there, recording that an emptied personal workspace “is harmless and can be reaped separately”. No reaper was ever written. Measured on dev:

select count(*) from workspaces w
where not exists (select 1 from workspace_user wu where wu.workspace_id = w.workspace_id);
→ 20

Twenty workspaces with zero members. “Harmless” was the wrong word: nobody can open, share or delete such a workspace through the product, because every one of those actions is authorised by a membership that no longer exists — while its projects, agents and scan rows stay on disk and in the billing tables indefinitely.

A workspace the departure leaves with zero members is now purged through the existing deleteWorkspace, so there is one definition of what a workspace is made of.

Only a claimable invitation keeps a workspace alive

Section titled “Only a claimable invitation keeps a workspace alive”

An invited address that has not accepted yet is a member who has not arrived; reaping on emptiness alone would delete a workspace out from under someone mid-invite. But only an invitation that can still be claimed means that. Until 2026-10-16 any row in user_invitations spared the workspace — ACCEPTED and REVOKED ones included — so an account whose invitee had already joined and left kept its workspace for ever (dev: 20 of 21 memberless workspaces had no claimable invitation).

workspace after the detachoutcome
other members remainkept — it was shared, not personal
no members, an invitation PENDING and not expired (expires_at NULL or in the future)kept for the invitee, logged at INFO
no members, only ACCEPTED / REVOKED / expired invitations, or nonepurged

The predicate is MemberlessWorkspaces.CLAIMABLE_INVITATION, the SQL twin of UserInvitation.isClaimable(); “now” is bound from the JVM, not LOCALTIMESTAMP, so the database session time zone cannot move the expiry. The decision lives in MemberlessWorkspaces (toPurge returns ids; the caller performs the purge) so it is testable without standing up a deletion.

reapMemberlessWorkspaces calls deleteWorkspace on this, so it does not pass through the transaction proxy. That is intended, not an oversight to route around with a self-injected bean: deleteWorkspace is @Transactional with the default REQUIRED, so a proxied call would join deleteAccount’s transaction anyway, and running it inline keeps the account and its emptied workspaces a single atomic unit. A failure part-way cannot leave an account deleted with its workspaces half-purged.

A static-analysis rule (Sonar java:S6809) flags the pattern. It is correct about the mechanism and wrong about the intent here.

AccountDeletionService.deleteWorkspace is the one definition of a workspace’s footprint, used by account deletion and by the reaper below. Since 2026-10-16 it also:

  • removes project groups — member-project grants (workspace_member_projects) are deleted, the workspace’s projects are detached from their groups, then project_group rows are deleted. Before, the project_group → workspaces foreign key made the whole account deletion roll back (a 5xx) for any workspace that had a group;
  • revokes every workspace credential instead of leaving it live: API keys (sandbox_api_key), Gatekeeper CI keys (gatekeeper_ci_key), coding ingest tokens (coding_ingest_token) and MCP grants (mcp_grant) are marked revoked — rows are kept for audit, not deleted — alongside the existing session revoke. Any revocation logs [workspace-delete] revoked credentials for workspace_id=… api_keys=… ci_keys=… ingest_tokens=… mcp_grants=…;
  • leaves workspace_subscription alone — billing state is not the deletion’s to rewrite;
  • deletes the workspace’s policies (since 2026-10-15) — policy_rule, policy_assignment, policy_version and policy rows of the workspace, the scan-profile policies of its projects, and every collector assignment to its agents (AccountDeletionService.deleteWorkspace);
  • purges the workspace’s OpenSearch data after the transaction commits (next section).

The SQL lives in WorkspaceRemnants (detachGroups, revokeAccess).

A repository row shared with another workspace (2026-10-23)

Section titled “A repository row shared with another workspace (2026-10-23)”

code_repository holds one row per repository URL, shared by every workspace that scans it; its user_id records who connected it first, not who owns it. Account deletion removes only rows no surviving project uses and keeps the others. Until backend bb8163f1 a kept row still named the deleted user, so DELETE FROM users hit the code_repository.user_id foreign key and the whole account deletion rolled back with a 500 — measured on dev when a QA account that had first connected Java-Techie-jt/spring-boot-crud-example was deleted while four other workspaces used the row. The kept row now passes to the owner of a surviving project that uses it (WorkspaceDeletionPostgresTest.sharedRepositoryRowPassesToASurvivingOwner).

The privacy policy promises deletion “within a commercially reasonable period”. deleteWorkspace never touched OpenSearch, so every per-workspace index, monitor, detector, notification channel and Dashboards saved object outlived its workspace. Measured 2026-10-16: dev held 667 indices (219.5 MB) of workspace 2903, prod 284 log-analytics-3103-* indices (184.6 MB, 1.22 M docs) — neither workspace existed on that estate.

WorkspaceOpenSearchPurger.purgeAfterCommit(wid) registers an after-commit hook, so a rolled-back deletion purges nothing; a failure in the purge is logged ([ws-os-purge] workspace=… purge failed) and left to the orphan janitor. purge(ws):

  1. Refuses a segment that is not 1–9 digits (not_numeric — the legacy logrite tenant is never purged), when OpenSearch credentials are missing (no_opensearch_credentials), and when the workspace still exists (workspace_exists, checked in a fresh transaction).
  2. Per index family, resolves <family>-<ws> and <family>-<ws>-* and keeps only names whose workspace segment is exactly ws (IndexNames.extractWorkspaceSegment) — so purging 12 never touches log-analytics-123 or log-analytics-1. Data streams are logged, never deleted.
  3. Deletes those indices by explicit name, in comma-separated batches of ≤ 3000 characters (a 404 retries one by one; 404 means already gone). No wildcard DELETE is ever sent.
  4. Deletes the workspace’s retention ISM policy and index template (404 ignored; never the legacy shared id).
  5. Deletes every object in the three ownership tables (opensearch_object_ownership, saved_object_ownership, notification_config_ownership): monitors, detectors (stopped first), ISM policies, notification configs, Dashboards saved objects. The ownership row is deleted on 2xx or 404 and kept on any other failure, so a later run retries it.

It ends with [ws-os-purge] workspace=… indices deleted=… failed=… ownership deleted=… kept=….

Two token-guarded routes clean up what predates this change. Both live on WorkspaceAdminController under /internal/admin, take header X-Logrite-Admin-Token (backend env LOGRITE_ADMIN_TOKEN; unset → 403 admin endpoint disabled, wrong → 403 invalid admin token, constant-time compare — the same guard as the licence admin routes) and the body {"dryRun": true, "workspaceIds": [...]}. dryRun defaults to true; a real run requires workspaceIds (400 otherwise; at most 500 ids), and only ids the dry run would list are acted on.

RouteDry run returnsReal run, per id
POST /internal/admin/workspaces/reap-memberless{dryRun, count, workspaces:[{workspaceId, name, createdAt, projects, projectGroups, subscriptionStatus}]} — memberless workspaces with no claimable invitationreaped, or skipped with not_a_candidate, not_found, has_members, claimable_invitation, active_subscription:<STATUS> (ACTIVE / PAST_DUE; TRIALING is reaped) or error: …
POST /internal/admin/opensearch/orphans{dryRun, count, workspaces:[{workspaceId, indices:[{name, docs, bytes}], ownershipRows:{opensearchObjects, savedObjects, notificationConfigs}}]} — numeric segments in OpenSearch or the ownership tables with no row in workspacespurged with indicesDeleted, indicesFailed, ownershipRowsDeleted, ownershipRowsKept, or skipped with not_an_orphan or the purger’s refusal reason

The reaper re-checks every condition inside its own transaction, writes the governance audit event workspace.reaped before calling deleteWorkspace, and a failure is reported in the result rather than thrown. Each orphan purge is audited as opensearch.orphans.purged.

OpenSearchOrphanJanitor runs the orphan purge on a schedule when LOGRITE_OPENSEARCH_ORPHAN_JANITOR_ENABLED=true (default false), at LOGRITE_OPENSEARCH_ORPHAN_JANITOR_CRON (default 0 40 4 * * *, 04:40 daily), one workspace at a time; a failing workspace does not stop the sweep.