Multi-tenancy
Measured Cloud is schema-per-tenant multi-tenant. One Postgres database hosts every org's metadata; each org gets its own schema for its tenant-scoped data (datasources, assets, rules, validations, results, anomalies, suggestions).
Why schema-per-tenant
| Option | Trade-off |
|---|---|
| One DB per org | Cleanest isolation; expensive to operate. |
| Schema per org | Strong isolation via search_path; one DB to operate. ← Measured Cloud chose this. |
Shared tables with org_id filter | Cheapest; one missing WHERE org_id = … is a data leak. |
The boundary lives at the request layer — every authenticated request resolves a Principal, and the DB session is opened with SET search_path TO tenant_<org_id>, public. Tenant-scoped queries hit the org's schema; cross-org references (orgs, users, tokens, LLM configs) live in public and remain readable.
Defense in depth: the runtime guarantee is reinforced by HARDEN-10 (Postgres RLS +
org_idcolumn) and HARDEN-11 (resetsearch_pathon connection checkout). These are tracked, not yet shipped.
Workspace isolation: dual-layer enforcement
Within a tenant schema, workspaces carve the org's data into logical slices. Measured Cloud enforces workspace isolation at two layers, both active on every request:
-
Application layer (TENANCY-1). A SQLAlchemy
do_orm_executelistener splices aworkspace_id IN (<accessible>)filter into every SELECT / UPDATE / DELETE against a workspace-bearing tenant table. Abefore_flushlistener does the equivalent for ORM unit-of-work writes (session.delete(obj)/ dirty-attribute UPDATEs). The accessible set is derived from the principal's directworkspace_membershipsplus team grants; org super admins bypass filtering. -
Database layer (TENANCY-2). A Postgres Row-Level Security policy (
workspace_isolation) keyed on theapp.accessible_workspacesGUC is attached to every workspace-bearing tenant table. The GUC has four recognised states:GUC value Effect unset ( NULL)unrestricted — admin / CLI / unscoped session path "*"unrestricted — explicit admin sentinel ""(empty string)no concrete-workspace rows visible (org-wide workspace_id IS NULLrows still pass)"<uuid>,<uuid>,…"only those workspaces are visible (plus org-wide rows) The policy itself is permissive for the unset state, so the fail-closed default for request-scoped sessions is enforced one layer up: a pool-checkin listener in
api/src/dq_cloud_api/db/workspace_isolation.pyresets the GUC to""every time a connection is returned to the pool. A request handler that forgets to callset_configtherefore lands in the empty-list state and sees zero concrete-workspace rows — not the unset/admin state. Admins and the CLI bypass this either by running outside the listener path (raw connections, alembic) or by explicitly setting the GUC to"*". The policy is declaredAS RESTRICTIVE, so it composes with the HARDEN-10org_isolationpolicy via AND: a row is visible iff both the org check and the workspace check pass.
The DB-layer policy is the load-bearing guarantee. Even if a router emits raw text("DELETE FROM datasources WHERE id = ...") — which sidesteps the ORM and therefore the application listener — the RLS policy still hides rows whose workspace_id isn't in the caller's access set. A buggy or forgotten filter at the application layer becomes a no-op rather than a cross-workspace data-loss event.
Rows with workspace_id IS NULL are org-wide: every workspace member of the org can read them, and an org-wide asset survives the empty-access-set deny path (a workspace-less org member can still see them). This matches the HARDEN-10 convention.
Restricted-asset filtering (third dimension)
Stacked on top of the workspace (and team) predicates, the same application-layer listener enforces the restricted-asset dimension: data_assets.is_restricted = true rows — and the validation results about them — are hidden from viewers whose viewer_access_level is restricted on their membership row. A workspace admin sets that level on Settings → Members (see Restricted assets). The scope is resolved per request from the principal's memberships — per workspace, with org-wide flagged rows hidden fail-closed whenever the principal is a restricted viewer anywhere — and a hidden asset reads as a 404, never a 403, so the flag doesn't become an existence oracle. Unlike the workspace dimension there is no Postgres RLS policy for this filter yet; the session listener is the enforcement point. Machine principals (agent / SCIM tokens), the worker, and the CLI are never restricted — the flag only narrows human viewers. See Data assets → Restricted assets for the user-facing semantics and the admin-only flag-change rule.
Public vs. tenant data
| Schema | What's in it |
|---|---|
public | organisations, users, org_memberships, workspaces, workspace_memberships, agent_tokens, personal_api_keys, refresh_tokens, llm_configs, audit_events, global_audit_events, identity_providers, invites, custom_expectations. Per-org data_docs_config and catalog_sync_config knobs live as columns on organisations. |
global_audit_eventsgrowth bound. This table captures auth events that have no org context (failed logins for unknown emails, rate-limit hits where no user could be resolved). Because it accepts traffic from anonymous probes it grows in proportion to public-internet attempts, not active customers. The worker'sprune_global_audit_eventscron runs daily at 03:30 UTC and deletes rows older thanGLOBAL_AUDIT_EVENT_RETENTION_DAYS(default 90 days, see Configuration). Set the env var to0to opt out — the table will then grow unbounded on an internet-exposed deployment.
Audit tables are append-only. Both
audit_eventsandglobal_audit_eventscarry aBEFORE UPDATE OR DELETEtrigger (migration 030) that raises on any in-place edit or unguarded delete, so the single application DB role can't silently rewrite or erase the trail after a compromise. The one sanctioned mutation is the retention prune above — it opts in by setting the session-localdq_cloud.audit_pruneGUC, which the trigger checks. A least-privilege Cloud SQL role split (read-only app role, privileged prune role) is the complementary infra-side control and remains tracked separately. |tenant_<org_id>|datasources,data_assets,asset_dependencies,batch_definitions,rules,validations,validation_rules,execution_runs,rule_suites/validation_configs/checkpoints(legacy, read-only),validation_results,result_comments,scheduled_runs,run_queue,alert_rules,alert_events,profile_runs,anomaly_events,ai_suggestions,metric_runs,context_config,lakehouse_snapshot_history. |
Orgs, workspaces, and roles
Organisation
└── Workspace (one or many)
└── Datasources, assets, rules, …
The Phase-1 (Measured Cloud) model has six roles across three scopes. A user is assigned a role and a corresponding workspace or team — except the two org-wide roles, which span the whole org:
| Scope | Role | What you get |
|---|---|---|
| Org | super_admin | Full authority across the whole org (the former "owner"). Manages members, tokens, billing, RBAC. |
| Org | technical_admin | Settings / integrations / tokens, plus the org's data-source connections: sees every workspace in the left-nav tree, and can add data sources and see or edit the existing ones under Data Assets — but never the data assets, rules, validations or results themselves, never deletes a data source, and has no user management and no billing. |
| Workspace | workspace_admin | Manages teams and every data mutation within one workspace. |
| Team | team_admin | Manages members within one team — no data access. |
| Team / Workspace | editor | Create / edit rules, schedules and assets. (Reuses the editor string.) |
| Team / Workspace | viewer | Read-only access + alerts, with an unrestricted / restricted access level. (Reuses the viewer string.) |
The six roles (plus billing) are the only role strings the product stores or accepts. The pre-Phase-1 owner, admin and member strings are retired: public migration 046 rewrote every stored one (org and invite owner / admin → super_admin, workspace and team admin → workspace_admin, member → editor), and the role CHECK constraints on memberships, invites, service accounts and SSO providers now reject them, as do the API request bodies (422). The one remaining tolerance is read-side: an access token minted before that migration (it lives 15 minutes) may still carry a legacy string, and the API maps it onto the canonical role when it reads the token. New code, seed scripts, SCIM role mappings and hand-written SQL must use the canonical strings. The canonical matrix lives in api/src/dq_cloud_api/auth/roles.py.
Effective role
A user can reach a workspace by more than one path: a direct workspace_memberships row (hand-managed) or membership in a team that belongs to that workspace. A team is a subdivision of a single workspace (teams.workspace_id); a member's role on the team lives on team_memberships.role. The effective role on a workspace is the maximum role across every such path, on the ladder super_admin > technical_admin > workspace_admin > team_admin > editor > viewer. The API resolves it in a single indexed query against workspace_memberships and team_memberships JOIN teams ON teams.workspace_id, caches the result per request, and surfaces it as WorkspacePrincipal.workspace_role. A team membership can only raise a user's access — it never downgrades a stronger direct membership. Org super admins bypass the lookup and are admitted to every workspace by definition. (billing is org-level only and never sits on a team membership.) An orphan team — one whose workspace_id is NULL (it had no workspace to re-parent onto, or was provisioned by SCIM/SSO before a workspace was assigned) — grants no workspace access until a super admin assigns it a workspace.
User invites + a UI to manage members ship in FEATURE-38: a super admin or workspace admin mints a dqi_ invite token via Settings → Members → Invite teammate; the operator forwards the resulting invite_url to the recipient out-of-band (SMTP wiring is tracked separately in PARITY-7). The recipient opens the URL, sets a password + display name, and is signed in at the invite's role.
The invite modal exposes the full six-role model (#669): pick the role, and the form asks for the scope that role needs — a workspace for workspace_admin / editor / viewer, a team for team_admin, or nothing for the org-wide super_admin / technical_admin. The role options are gated to what you may grant (a workspace admin can grant workspace + team roles but not a super admin), mirroring the server's grant ceiling. A team-scoped invite seeds a team_memberships row (added_via='invite') and an editor org-membership floor; a workspace_admin invite seeds a workspace_memberships row plus the same floor (workspace_admin is not a valid org role). Workspace membership rows are managed on the same Members page: a super admin opens a member's Workspace access from the org-wide list to add them to any workspace, change their role there or remove them (a super admin's own access is inherited by every workspace and shown read-only), and a workspace admin manages the current workspace's members. See Invite teammates. Team membership is managed on Settings → Teams. SAML / OIDC SSO is configured per-org — see Configure SSO. Setting an IdP to enforced disables password login for the org; members sign in through the IdP and are JIT-provisioned with the role you choose on the provider.
Team-scoped resources
Within a workspace, five resource kinds can additionally be pinned to one of the workspace's teams: schedules, alert rules, and the retired rule suites, validation configs and checkpoints (existing rows keep their team but can no longer be created or edited). Each carries an optional team_id. Validations and data assets have their own team model, described under Team ownership below:
team_id = null(the default) means workspace-shared — visible to every member of the workspace, exactly as before teams.- A concrete
team_idmeans team-scoped — visible only to that team's members, plus anyone whose effective role on the workspace is workspace-admin-or-higher (and super admins, who are unrestricted). The samedo_orm_executelistener that enforces workspace isolation stacks the team predicate on top — and, like the workspace dimension, the team boundary is dual-layer: ateam_isolationPostgres RLS policy keyed on theapp.accessible_teamsGUC (same four states asapp.accessible_workspaces— unset,"*","", CSV-of-UUIDs; declaredAS RESTRICTIVEso the three policies AND together) backstops the listener at the database, so even raw SQL that sidesteps the ORM can neither read a sibling team's rows nor re-pin a reachable row to a team outside the caller's set.
Assigning a team. Every create/update surface for the five resources accepts an optional team_id in the request body, validated by one shared server-side guard:
- The team must exist in your org, not be deleted, and belong to the same workspace as the resource — otherwise the API returns
422 "Invalid team for this workspace"(one deliberately uniform message, so team UUIDs can't be probed across orgs or workspaces). Teams are workspace-pinned, so a team assignment on a resource with no workspace anchor at all (an org-scoped URL with nothing to derive a workspace from) is a422 "Assigning a team requires a workspace-scoped resource". - You must be able to reach the team: a member of it, or workspace-admin-or-higher in its workspace — otherwise
403. - An explicit
team_id: nullon update un-scopes the resource back to workspace-shared. Un-scoping widens the resource's visibility from one team to the whole workspace — the inverse of the create-time widen rule below — so it requires workspace-admin-or-higher (or a super admin); members of the resource's current team do not qualify on their own. Un-scoping keeps the resource's workspace pin: the resource becomes workspace-shared, never org-wide. (For alert rules, restoring the legacy org-wide shape is a deliberately separate operation thatteam_idsemantics never perform implicitly — it is not currently exposed.) - More generally, on update any transition away from the resource's current team — re-pinning it to a different team just as much as un-scoping it — and any move of the resource to a different workspace (whether it is team-scoped or workspace-shared: relocating a row hides it from its current workspace's members either way) requires workspace-admin-or-higher on the resource's current workspace (or a super admin). Authority is always evaluated against where the resource is, never against the destination of the request: being a member of both teams, or a workspace admin of the destination workspace, is not enough to take a resource away from the team — or the workspace — that currently holds it. The workspace-move rules apply to data assets too, even though they carry no
team_id: relocating an asset (or capturing an org-wide one into a workspace) is the same visibility change, gated by the same matrix. Update requests also lock the target row for the duration of the transaction, so two concurrent updates can't interleave into a combined scope transition neither was authorized for. - On the org-scoped URLs (no
{workspace_id}in the path) an update body may also carryworkspace_id. A concrete value is a workspace move, gated as above, and the destination must be a live workspace of your org — anything else (a typo'd UUID, another org's workspace) is a422 "Invalid workspace for this organization", one deliberately uniform message checked for every caller including unrestricted owners, so a row can never be orphaned onto a workspace that doesn't exist. An explicitworkspace_id: nullis a transition to org-wide: it is rejected with422whenever a team is involved (a team-scoped resource can never go org-wide in one request — that includes sendingteam_id: nullandworkspace_id: nulltogether, because un-scoping keeps the workspace pin), and for a team-less resource it requires workspace-admin-or-higher on the resource's current workspace, org-wide visibility being the maximal widening. Note that onlyPUTactually applies a permittedworkspace_id: null;PATCHskips null fields, so the request is validated identically but the pin is kept. - Narrowing a previously shared resource onto a team (
null → team) is deliberately split: pinning a workspace-shared resource onto a team you belong to, inside its own workspace, stays member-level — the ergonomic default for editors organizing their own workspace's assets. Pinning an org-wide resource (a legacy alert rule with no workspace) onto a team hides it from every other workspace and stamps a workspace onto it as a side effect, so it requires workspace-admin-or-higher on the destination workspace. - Omitting
team_idon update leaves the pin unchanged.
Inheritance on create. When you create a child resource on a team-scoped parent and don't say otherwise, the child follows the parent's team, so a team's pipeline stays coherently scoped without repeating team_id on every call:
| You create… | …referencing | Result when body omits team_id |
|---|---|---|
| a schedule | team-owned validations (validation_ids) | schedule inherits the team (the restricted validation's team, or a team every target shares); a schedule running a team-restricted validation must belong to that team |
| an alert rule | a team-owned validation | alert rule inherits the validation's team (and is pinned to its workspace) |
If the parent's pinned team has since been deleted, the create fails with a self-explanatory 422 ("the referenced parent's team no longer exists") — pass team_id explicitly or re-scope the parent.
A body-supplied team_id (including an explicit null) wins over inheritance, but must still cohere with the parent:
- a
team_idthat names a different team than the parent's is rejected with422— two teams' assets can't be mixed in one chain, even by a super admin; - an explicit
nullchild under a team-scoped parent widens the parent's visibility to the whole workspace, so it requires workspace-admin-or-higher — and the child is pinned to the parent's workspace ("widen" means workspace-shared, never org-wide); - a workspace-shared (
team_id: null) parent accepts any child — team-scoped or shared.
A parent reference that doesn't resolve under your own visibility (for example a validation belonging to a sibling team) triggers neither inheritance nor coherence; the child simply lands workspace-shared, matching how those references behaved before team scoping. A team-scoped parent in a different workspace than the child is likewise treated as no parent — its team never propagates across workspaces (and on the workspace-scoped alert-rule URL, a validation pinned to a different workspace than the URL's is a 404 outright).
Derived data follows the parent's team. The rows a team-scoped parent produces are team-scoped too, enforced by the same session listener through the parent linkage (a correlated EXISTS against the parent's team_id):
- Validation results are visible only when every parent they link — the validation (or, for a legacy run, the checkpoint) and, for a result written before rule suites were retired, its rule suite — is workspace-shared or one of your teams'. A result detached from its parents (for example after its validation was deleted; run history is preserved with a nulled pointer) is workspace-shared.
- Incidents carry their own
team_idandteam_restricted, like execution runs: an incident opened by an alert takes its run's team, and a manual one takes the team of the data asset or rule it is about. When a validation's team changes, its incidents follow it. (Before migration 064 an incident's team was derived from its validation or checkpoint at read time; the migration copied that team onto each incident, so none changed visibility.) - Deleting a team-scoped parent declassifies its derived history. Deleting a validation preserves its validation results by nulling the parent pointer, and a detached row's visibility is no longer derived from that team: the surviving results become workspace-shared, failure payloads included (a result still follows its other parent if one remains linked). The alternatives were considered and rejected: deleting the derived rows with the parent would destroy the run/triage audit trail, and snapshotting the team onto the detached rows would pin history to a team that may itself be deleted — preserving history wins, and the widening is the documented behaviour. Delete a team's validations only when you accept its run history becoming visible to the whole workspace.
- A hidden derived row reads as a
404on get-by-id and is absent from lists, search, exports, and the dashboard aggregates — the same no-existence-oracle posture as the workspace dimension. - Metric runs are the documented exception: the
metric_runstable carries no validation, suite, or asset linkage at all (rows are minted by the generic metrics endpoints with a free-form payload), so there is nothing to derive a team from. Metric runs remain visible to every member of their workspace until a future schema change adds a linkage. - Profile runs, anomaly events, and AI suggestions derive from a data asset. They follow the asset's team only when the asset is restricted to its team (see Team ownership below); otherwise they stay visible to the whole workspace.
The team filter in the UI. The sidebar's team selector, under the workspace selector, filters the active workspace by team ("All teams" by default). A super admin or workspace admin can pick any of the workspace's teams, or "All teams". Anyone else sees the team they belong to; someone in several teams can pick one of theirs or "All my teams". The selector is hidden when the workspace has no teams. With a team active, the team-bearing list pages (validations, schedules, alert rules) narrow to that team's rows plus the workspace-shared ones. The dashboard's other widgets (pass rate, recent runs, the 30-day trend, anomalies) are derived from validation results, which the server already narrows to the caller's enforced team visibility; the client-side team filter does not further narrow them — the page says so in an explicit scope hint rather than showing numbers a team filter can't actually narrow. Data sources are workspace-shared by design and never team-owned.
Incident visibility. An incident's team_id and team_restricted are its own columns (read-only on the wire): its run's team, or its asset's or rule's for a manual incident. The Incidents page shows the team and narrows on it client-side under the team filter, with the same shared-rows-stay-visible semantics. It is also enforced, both by the session listener and by the workspace_isolation and team_isolation row-level security policies on incidents (added by migration 064), so a team-tier user cannot read a sibling team's restricted incidents through the API either. Each team's detail page under Settings → Teams also shows a read-only "Team resources" summary — counts of the validations / schedules / alert rules the team owns, with links into the filtered list pages — so a workspace admin can see what a team owns before re-parenting or deleting it. It counts the team's data assets too.
Team ownership: assets, rules and validations
A data asset can have an owning team, and its rules always carry the asset's team. A validation has its own owning team, independent of its rules'. An item with no team reads Workspace-shared. The owning team shows as a badge wherever the item appears: the Data Assets list and asset page, each rule on the asset page, the Validations list and validation page (and each asset's rules there), and the Results list (a run shows its validation's team). That holds in the "All teams" view too, so you can always tell which team an item belongs to.
The team is a label unless you restrict it. By default, an owned asset or validation stays visible to everyone in the workspace; the team is just who owns it. To limit visibility, turn on Restrict to team (team_restricted: true). Then only that team's members, workspace admins, and org owners/admins can see the item:
- a restricted asset hides with its rules and default validation (so its asset and rule run results), its profile runs, anomalies and AI suggestions;
- a restricted validation hides with its runs, results and fired alerts (run history is always kept, it just follows the validation's team).
An alert rule only fires on a result its own audience can see: a workspace-wide rule (no team) never fires on a restricted item's results, and a team's rule fires on its own team's.
Restriction is enforced the same two ways as the rest of the team boundary: the session listener and the team_isolation RLS policy. On these tables the policy passes any row whose team_restricted is false. Validations and runs that already had a team before this feature shipped were a visibility scope then, so they were migrated as restricted and stay exactly as visible as they were.
Who can change it.
| Change | Who |
|---|---|
| Give a workspace-shared asset or validation a team you belong to | editor tier (asset edit / validation edit) |
| Move it to another team, or back to workspace-shared | workspace admin or higher on its workspace |
| Turn Restrict to team on or off | workspace admin or higher on its workspace |
Authority is checked against the item's current team and workspace, never the request URL. Restricting needs an owning team (422 otherwise), and clearing the team clears the restriction. In the UI the controls are on the asset's Edit dialog and on the validation's New and Edit dialogs. Someone without the authority sees them disabled, with a "Requires …" tooltip. An asset's default validation always takes its asset's team, so you change it on the asset (editing it on the validation is a 409).
Mixing teams is blocked only when a restriction is on. Two teams' labels can meet in one validation. With a restriction in play:
- a validation restricted to team A can't hold team B's rules (adding one of team B's rules is a
422"mixing two teams' assets"); - a restricted asset's rules only fit validations restricted to the same team. Anywhere else they would show a restricted asset's results to people who can't see the asset.
This holds on every way of putting rules in a validation: choosing an asset's rules (all of its current rules, or some), adding a rule, editing which validations a rule is in, and picking validations while you add a rule or accept an AI suggestion. A validation you create on the spot while adding a rule takes the asset's team, restricted, when the asset is restricted, and no team otherwise.
Changing an asset's or a validation's team (or its restriction) re-checks this against what it's already in, and returns a 422 that names the conflicting assets or validations. Items you can't see are only counted. A team change flows to the asset's rules and default validation, and a validation's to its run history.
Registering a table another team restricted. Discovery's Register (and a dbt manifest import) treats a table already registered as an asset restricted to another team as "already registered" and skips it, without revealing the hidden asset.
Deleting a data source bins (and later purges) every asset under it, including assets restricted to a team you aren't in; restoring it from the recycle bin brings them all back.
Switching workspaces
The active workspace scopes everything you see — data sources, data assets, rules, validations, results — so it's surfaced at the top of the sidebar with a workspace selector (and, below it, the team selector). The selector shows the current workspace name prominently and, on click, lists every workspace you can reach (super admins and technical admins see all of the org's workspaces; everyone else sees only the ones they're a member of, via direct membership or a team that belongs to the workspace). A technical admin uses it to reach each workspace's data sources; it does not give them the workspace's data. Selecting a workspace re-scopes the whole app to it and remembers the choice across reloads. Super admins also get All workspaces, the org-wide dashboard; while it is open the selector reads "All workspaces". Someone who can reach only one workspace doesn't see the selector at all. When the sidebar is collapsed, both selectors stay available as icons. Users who can manage workspaces (super admins) also get a + Create workspace action in the selector; the control is visible-but-disabled, with a "Requires …" tooltip, for anyone without that permission. The list comes from GET /api/v1/organizations/{org_id}/workspaces.
Deleting a workspace
A super admin can delete a workspace from Settings → Workspace → Danger zone. The dialog lists what the workspace holds and what the delete will stop ("2 queued or running runs will be cancelled and 1 schedule will stop"). It asks you to type the workspace's name and to enter your password again. You can delete the organization's only workspace too, default included: the dialog says so, and until you create or restore one the app shows a No workspaces page in place of every workspace-scoped view (super admins get Create a workspace and Restore there; everyone else is told to ask a super admin). Settings stay reachable throughout.
Deleting a workspace archives it rather than erasing its history:
- It disappears for everyone, super admins included: it leaves the workspace selector, and its pages and API routes answer
404. - Its queued and running runs are cancelled, the same way Stop cancels a run. A data asset the worker is checking is interrupted within a few seconds. An agent-mode check that the DQ Agent has already started finishes on the agent, but its result is discarded: it can't reopen the run or raise an alert. Queued profile runs are cancelled too, and any background job picked up for the workspace later (a run, a profile, an AI suggestion) exits without doing anything.
- Anything else that would keep acting inside it stops. Its schedules are turned off (a firing that races the delete is cancelled, not failed). The agent tokens, personal API keys and service accounts pinned to it are revoked, its pending invitations expire and its teams are retired.
- Its execution runs, results and alert history are kept for the organization's records. Nothing inside it is deleted. In organization-wide views (All workspaces results and the org dashboard's breakdown by workspace) its history is labelled with its name and "(deleted)", for example "Sandbox (deleted)". Those rows are read-only and don't open the workspace.
- Its slug is released, so a new workspace can use it.
The delete is recorded in the audit log with what the workspace held, what it cancelled and which schedules it turned off. The API is DELETE /api/v1/organizations/{org_id}/workspaces/{workspace_id}?confirm_name=<name> (super admin, step-up). GET …/workspaces/{workspace_id}/deletion-preview returns the inventory and the counts the dialog shows.
Restoring a workspace
Deleted workspaces are listed under Settings → Workspace → Deleted workspaces. A super admin can restore one with Restore and a confirmation. Anyone else who belonged to it sees it listed with the button disabled. Nothing purges a deleted workspace, so you can restore it at any time.
Restoring it:
- Brings it back under its original name and slug, for everyone who was a member. The teams the delete retired come back too.
- Turns back on the schedules the delete turned off. They fire from their next scheduled time; runs missed while the workspace was deleted aren't caught up. A schedule that was already paused before the delete stays paused.
- Leaves cancelled runs cancelled. Revoked agent tokens, API keys and service accounts stay revoked, and expired invitations stay expired. Create new ones.
If an active workspace now has the same name or slug, the restore is refused (409); rename that workspace first. A restore also counts against your plan's workspace limit. It is recorded in the audit log. The API is POST /api/v1/organizations/{org_id}/workspaces/{workspace_id}/restore, and GET /api/v1/organizations/{org_id}/deleted-workspaces lists the deleted workspaces.
Provisioning a new tenant
Every org gets its tenant_<org_id> schema when it is created, before the org row itself exists. That applies to self-service signup, the first-admin seed (dqc seed / scripts/seed_first_admin.py) and the dev seed. The schema is built by the tenant Alembic chain in one transaction, so it arrives with every table, the row-level-security policies and an alembic_version_tenant stamp at the current head. If provisioning fails, signup returns 503 and nothing is created. An org never exists without its schema.
Deploys roll every existing tenant forward with dqc migrate-all-tenants. If an org somehow exists without its schema, migrate-all-tenants recreates it and reports it, as long as the org's audit log shows it never had tenant data. Otherwise the schema may have been lost, and the command fails that org as possible data loss instead of recreating it empty. As an operator you can run dqc provision-tenant <org_id> to recreate a schema on purpose. See reference/cli.md.
Agent-mode datasources
For customers whose data sits behind a firewall, a DQ Agent runs inside that network and registers datasources with Measured Cloud using a dqa_… token. The agent token is scoped to one org + (optionally) one workspace; revoking the token disables every datasource it registered. The agent also executes validation runs (including asset and single-rule runs) — full Great Expectations runs against the customer DB happen on-prem, and only redacted metadata (counts, success flags, column names) crosses back. See Agent mode for the dispatch + metadata cut, and Deploy the agent for the operator runbook.
Where does my data live? (data residency)
Measured Cloud is self-host-first, so the honest answer is "wherever you, the operator, deploy it" — there is no central control plane and no managed multi-region replication. What that means in practice, network call by network call:
| Surface | Where the data sits |
|---|---|
| Customer datasource data (rows, samples, query results) | Read by the worker process only. Never persisted in Measured Cloud — the worker executes Great Expectations in a sandboxed subprocess and only retains the summary metrics + per-expectation pass/fail counts. |
| Metadata + result snapshots (orgs, users, datasources, assets, rules, validation results, anomalies, AI suggestions) | The operator's Postgres, in the schema-per-tenant layout described above. |
| Agent-mode datasources | The DQ Agent runs inside the customer's network and proxies validation / profile work; row data never crosses the network boundary back to the API / worker. See agent-mode datasources above. |
| Encrypted secrets at rest (datasource credentials, LLM API keys, IdP client secrets) | Stored in the operator's Postgres, AES-256-GCM-encrypted under a per-tenant DEK that's wrapped by the KEK (env-fallback or AWS KMS — see HARDEN-16). |
| AI calls (suggestions, explanations, autoresolve, NL→SQL) | Outbound to the configured LLM provider — Anthropic (api.anthropic.com), OpenAI (api.openai.com), an Ollama instance you supply, or a custom OpenAI-compatible base URL. The provider, model, and API key are configured per-org via llm_configs; the fallback knobs (DEFAULT_LLM_*) live in Configuration. Per-org cost / token budgeting is tracked in HARDEN-25. |
| Outbound alerts | To the per-alert-rule destination (Slack webhook URL, Microsoft Teams URL, PagerDuty Events API, generic webhook URL, SMTP server). |
| Telemetry | Optional. Prometheus is scrape-pull (the API exposes /metrics); OpenTelemetry is configurable via OTEL_EXPORTER_OTLP_ENDPOINT and disabled by default. |
Measured Cloud surfaces a region claim so customers and auditors can confirm at a glance where a given deployment is running. The operator declares it via the DEPLOYMENT_REGION env var (suggested values: us-east-1 / eu-west-1 / apac-northeast-1 / self-hosted); it is rendered in the UI footer next to the version and returned by the public, unauthenticated GET /api/v1/deployment endpoint:
{
"data": {
"region": "eu-west-1",
"version": "0.1.0",
"sso_enabled": true,
"llm_provider": "anthropic"
}
}
Multi-region replication is out of scope today — pin a single region in the operator's hosting platform and the answer to "where is my data?" is that region.
Step-up auth for sensitive actions
Some endpoints mutate credentials or bearer secrets — minting or revoking an agent token, rotating an LLM API key, creating or updating a datasource, creating or updating an alert rule. A captured access token shouldn't be able to do those things without the user re-proving they hold the password, even though the session itself remains valid for routine read / write. Measured Cloud enforces a recent password proof on those routes (HARDEN-24).
Every access token carries an auth_fresh_at claim. /auth/login and /auth/elevate set it to now; /auth/refresh mints with auth_fresh_at = 0 ("never fresh"). The fresh_auth server dependency rejects requests whose auth_fresh_at is older than AUTH_STEP_UP_WINDOW_SECONDS (default 300 = 5 min) with 401 detail=step_up_required and the header WWW-Authenticate: StepUp. The UI catches that signal, prompts for the password inline, calls POST /auth/elevate to mint a refreshed token, and retries the original request.
| Protected endpoint | Reason |
|---|---|
POST /api/v1/.../agent-tokens | Minting a long-lived bearer secret. |
DELETE /api/v1/.../agent-tokens/{id} | Revoking a token a session shouldn't be able to kick offline silently. |
POST / PATCH /api/v1/.../llm-configs | Rotates the LLM API key (encrypted at rest, but the call carries plaintext). |
POST / PUT /api/v2/.../datasources | Rotates datasource credentials. |
POST / PATCH /api/v1/.../alert-rules | Rotates the Slack webhook / PagerDuty key / generic-webhook URL. |
Personal API keys (dqk_…) and agent tokens (dqa_…) carry auth_fresh_at = 0 by construction — they have no associated password, so they can never satisfy the gate. A token automation can read and write routine resources, but it cannot use itself to mint a sibling agent token or rotate the org's LLM key. That separation is deliberate.