docs: draft access policies v2 design spec#10804
Conversation
Working doc capturing the proposed access policies primitives (access_policies registry, required_access_policies on cubes/views and members, mask_unless_access_policies + mask, access_filters with apply_if_access_policies). Includes worked examples on a deals dataset and a "Potential gaps" section flagging open design questions. Lives at the root of docs-mintlify/ as .md (not .mdx) so it stays out of the published Mintlify nav. Made-with: Cursor
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
|
Claude finished @keydunov's task in 3m 6s —— View job Review: Access Policies v2 Design Spec
Overall this is a well-structured design doc. The three-layer model (project registry → cube/view gating → member-level access) is clean, the policy expression algebra ( That said, I found one significant design inconsistency and several smaller issues. I've left 8 inline comments on specific lines; here's a summary of the most important findings: Critical: View-cube member composition contradicts the ecommerce exampleThe spec states (lines 375–378): "Member access on a view is independent of the underlying cubes… the underlying cube member's parameters are not combined." But the ecommerce example (lines 599–685) includes This needs to be resolved before the spec is shared. Either the composition rule should say cube-level member policies are inherited unless overridden, or the example needs to redeclare the masking on the view. See inline comment for details. Other notable issues
Strengths
Suggestions for a future revision
|
| # views/order_revenue.yml | ||
| views: | ||
| - name: order_revenue | ||
| cubes: | ||
| - join_path: order_items | ||
| includes: [status, created_at, total_sale_price, count] | ||
| - join_path: order_items.products | ||
| includes: [brand, category, cost] | ||
| - join_path: order_items.users | ||
| includes: [country, traffic_source] | ||
|
|
||
| # No required_access_policies → open to everyone. cost is | ||
| # soft-masked for non-finance users (see products cube below). | ||
| access_filters: | ||
| # Region filter applies to everyone except org admins | ||
| - member: country | ||
| operator: equals | ||
| values: ["{ userAttributes.region }"] | ||
| apply_if_access_policies: | ||
| none_of: [org_admin] |
There was a problem hiding this comment.
Design inconsistency: View-cube member composition contradicts this example.
Lines 375–378 state:
Member access on a view is independent of the underlying cubes. A member exposed by a view uses the view's own
required_access_policies/mask_unless_access_policies/mask; the underlying cube member's parameters are not combined.
But this view includes cost from products cube without redeclaring mask_unless_access_policies on it. According to the composition rule, the cube's mask_unless_access_policies: [finance] (line 632) should not apply through the view — meaning cost would be unmasked for everyone.
Yet the summary table (line 681–685) says cost is masked for non-finance users through this view.
Either:
- The composition rule needs to say cube-level member policies are inherited by views unless explicitly overridden, or
- This view definition needs to redeclare
mask_unless_access_policies: [finance]andmask: -1on thecostinclude, or - The summary table is wrong and
costis actually unmasked through this view.
This is a critical ambiguity — getting it wrong in either direction leads to PII leaks or surprising access denials. I'd recommend resolving this before the spec is shared broadly.
| A member exposed by a view uses the view's own `required_access_policies` / | ||
| `mask_unless_access_policies` / `mask`; the underlying cube member's parameters are | ||
| not combined. This mirrors SQL column visibility — once a column is | ||
| exposed by a view, the view's grants are authoritative. | ||
| - **Access filters** on a view compose with the underlying cubes. | ||
| Filters from both layers apply. | ||
|
|
There was a problem hiding this comment.
This rule has significant security implications that the spec acknowledges in the "Potential gaps" section (line 759–763) but doesn't resolve. Consider adding a concrete recommendation here, e.g.:
Recommended: When a view includes a cube member that has
required_access_policiesormask_unless_access_policies, the view author should explicitly redeclare the access policy on the included member. A future lint rule will warn when cube-level policies are silently dropped.
Without this, a developer adding a new view that includes a PII-protected cube member can unknowingly bypass the cube's masking — and the "view is authoritative" rule means this is by design, not a bug.
| access_policies: | ||
| region_user: | ||
| user_attribute: securityContext.region | ||
| values: ["*"] |
There was a problem hiding this comment.
The values: ["*"] wildcard syntax here is not explained anywhere in the spec. What does it mean — "any non-null value"? "Any value at all including null"? "Attribute must exist"?
This needs a definition, or it will be a source of confusion. Consider adding a section on special values/wildcards in the policy expression reference.
| - [`apply_if_access_policies`](#) — scope when an access filter applies; omit to apply to everyone | ||
| - [`required_access_policies`](#) — strict member-access requirement | ||
| - [`mask_unless_access_policies`](#) — soft mask: members stay queryable but return `mask` unless the user satisfies a listed policy | ||
| - [`mask`](#) — value returned in place of real data for masked or denied members |
There was a problem hiding this comment.
The reference section says mask is the "value returned in place of real data for masked or denied members." But for denied members (hard deny via required_access_policies), the member is hidden from metadata and queries that reference it fail (line 240). No mask value is ever returned in the hard-deny path.
This should read just "for masked members" — the "or denied" part is misleading.
| - [`mask`](#) — value returned in place of real data for masked or denied members | |
| - [`mask`](#) — value returned in place of real data for masked members |
| `access_filters` is a list of row-level grants applied **after** the | ||
| user has passed `required_access_policies`. | ||
|
|
||
| Filters use the same format as REST (JSON) API query filters, |
There was a problem hiding this comment.
All [filter operators](#), [Ecommerce demo](#), and the Reference section links point to # (self-link). These should either be filled in with real anchors or marked as TODO so they're not mistaken for working links during review.
| Default mask: `MD5(<value>)` for strings (deterministic — joins and | ||
| grouping still work), `NULL` for numbers/booleans/times. Override | ||
| per-member: |
There was a problem hiding this comment.
Nit: The default mask for measures is underspecified. MD5(<value>) for strings and NULL otherwise makes sense for dimensions, but what does MD5(<value>) mean for a sum or count measure? The aggregated result is a number, so it would fall to NULL — but a count_distinct of a string dimension exposed as a measure could be surprising.
Worth adding a sentence clarifying that the default mask is determined by the member's type, not the underlying SQL type, to avoid ambiguity.
| - and: | ||
| - member: user_id | ||
| operator: equals | ||
| values: ["{ userAttributes.userId }"] | ||
| - member: country | ||
| operator: equals | ||
| values: ["{ userAttributes.region }"] | ||
| apply_if_access_policies: [sales] |
There was a problem hiding this comment.
The and: wrapper introduces a structurally different filter format (array of conditions under and:) compared to single-member filters. This is the only example in the doc using this syntax, and the grammar isn't formally defined.
A few questions for clarity:
- Can
or:also be used here for explicit OR within a single filter entry? - Can
and:/or:be nested (e.g.,and: [or: [...], ...])? - Where does
apply_if_access_policiessit relative toand:— is it always a sibling at the top level of the filter entry?
Consider adding a brief "Filter syntax" subsection that shows the full grammar of a filter entry.
| Globally override defaults via `CUBEJS_ACCESS_POLICY_MASK_STRING`, | ||
| `_NUMBER`, `_BOOLEAN`, `_TIME`. |
There was a problem hiding this comment.
Minor: CUBEJS_ACCESS_POLICY_MASK_STRING, _NUMBER, _BOOLEAN, _TIME are mentioned but not documented — what values do they accept? For _STRING, is it a literal string, a SQL expression, or a template? For _NUMBER, is 0 vs -1 vs NULL the expected range?
Even a one-line example per env var would help.
Extends the "Sales deals with regional Closed Won access" worked example with a subsection showing member-level required_access_policies on the region dimension. Translates the old-spec per-group member_level.includes pattern into the new declarative per-member form and adds a per-user field-visibility table. Co-authored-by: Cursor <cursoragent@cursor.com>
Working doc capturing the proposed access policies primitives (access_policies registry, required_access_policies on cubes/views and members, mask_unless_access_policies + mask, access_filters with apply_if_access_policies). Includes worked examples on a deals dataset and a "Potential gaps" section flagging open design questions.
Lives at the root of docs-mintlify/ as .md (not .mdx) so it stays out of the published Mintlify nav.
Made-with: Cursor
Check List