This is an automated email from the ASF dual-hosted git repository.
roryqi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new 73f2ddd913 [#12759] docs: add design doc for tag-based access control
(#12757)
73f2ddd913 is described below
commit 73f2ddd913e1d2fe756b3ebb7baec8d8cad29c55
Author: Bharath Krishna <[email protected]>
AuthorDate: Wed Oct 7 18:46:06 2026 -0700
[#12759] docs: add design doc for tag-based access control (#12757)
### What changes were proposed in this pull request?
Adds `design-docs/tag-based-access-control.md`.
An access rule is a `Policy` of type `system_access_control` whose
`content` carries an action, a role condition and a scope. The policy is
bound to a tag; any object carrying that tag becomes subject to the
rule.
### Why are the changes needed?
Authorization does not read tags today, so classification cannot drive
access — an organization that already labels tables `certified` or `pii`
must still enumerate grants object by object.
Follows the discussion in #12619 and reflects the model agreed there.
Four questions are left open for review rather than decided in this
revision, each presented with its options in the doc: where tags are
evaluated, how a tag allow composes with an RBAC deny, what happens when
a referenced role is deleted, and whether object-scoped `APPLY_TAG`
ships in this version.
Fix: #12759
### Does this PR introduce _any_ user-facing change?
No. Documentation only.
### How was this patch tested?
Not applicable — no code changes.
---
design-docs/tag-based-access-control.md | 844 ++++++++++++++++++++++++++++++++
1 file changed, 844 insertions(+)
diff --git a/design-docs/tag-based-access-control.md
b/design-docs/tag-based-access-control.md
new file mode 100644
index 0000000000..32673ae558
--- /dev/null
+++ b/design-docs/tag-based-access-control.md
@@ -0,0 +1,844 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+-->
+
+# Design of Tag-Based Access Control in Gravitino
+
+**Status:** draft for discussion. Each question in [Decisions](#decisions) is
settled here, with the
+alternatives recorded and the reason for not taking them.
+
+Discussion: [#12619](https://github.com/apache/gravitino/discussions/12619)
+
+---
+
+## Summary
+
+An access rule is a `Policy` of type `system_access_control` whose `content`
carries a set of
+privileges and a role condition. The policy is bound to a tag. Any object
carrying that tag
+becomes subject to the rule.
+
+```json
+POST /api/metalakes/prod/policies
+{
+ "name": "certified_access",
+ "policyType": "system_access_control",
+ "enabled": true,
+ "content": {
+ "privileges": ["SELECT_TABLE", "MODIFY_TABLE"],
+ "applicableRoles": ["analyst", "data_engineer"]
+ }
+}
+```
+
+```
+PUT /api/metalakes/prod/tags/certified/policies/certified_access
+ { "selector": { "type": "ALL_VALUES" } }
+
+POST /api/metalakes/prod/objects/TABLE/lakehouse.finance.orders/tags
+ { "tagsToAdd": [{ "name": "certified" }] }
+```
+
+Read together: *a caller holding either `analyst` or `data_engineer` may
select from and modify
+any table that carries the tag `certified`.* Any listed role satisfies the
condition, and every
+listed privilege is conferred to it.
+
+Only one thing is attached: the policy to the tag. The roles are values inside
`content`, not a
+second link. No new user-facing entity, REST resource or client API is
introduced.
+
+---
+
+## Background
+
+Gravitino authorizes metadata operations through RBAC. A grant names a
securable object and a
+privilege and binds them to a role; the authorization expression on each REST
endpoint evaluates
+those grants over the object's ancestor chain.
+
+Tags are a separate subsystem. They apply to catalogs, schemas, tables, views,
topics, filesets,
+models, columns and functions, carry assignment values (see
+[tag-assignment-values.md](tag-assignment-values.md)), and inherit down the
object hierarchy.
+Policy-on-tag ([policy-on-tag.md](policy-on-tag.md)) lets governance policies
be selected by those
+tags. Authorization does not read tags at all.
+
+So a label cannot drive access. An organization that already tags tables
`certified`, `pii` or
+`data_domain=finance` must still issue grants object by object to act on those
tags. New objects
+need new grants, dropped objects leave stale ones, and the rule itself is
written down nowhere — it
+exists only as the pile of grants someone remembered to issue.
+
+---
+
+## Scope
+
+### In this version
+
+- A rule of the form *(action, role condition)* bound to a tag.
+- `ALLOW` only.
+- Roles as the matched condition.
+- Evaluation inside the existing authorization-expression path, composing with
RBAC.
+- Reuse of the `Policy` entity, the policy-to-tag relation and
`PolicySelector`, so tag conditions
+ are written identically for governance and for authorization.
+- No new REST resource or client API. The only storage addition is an internal
derived index, not
+ written or read by any endpoint — see [Lifecycle](#lifecycle).
+
+### Not in this version
+
+| Excluded | Reason
[...]
+| ------------------------------------------------------------- |
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
[...]
+| `DENY` | A deny that
a descendant tag cannot undo is a separate problem. Allow-only means every rule
set has an answer and the order rules are applied in never matters.
[...]
+| Users and groups as the matched condition | The
condition schema can gain them later without changing the model.
[...]
+| Row filtering and column masking | Distinct
policy types; this design governs whole-object decisions.
[...]
+| Cross-tag conditions | A rule
matches one tag. Conditions spanning several tags await the `EXPRESSION`
selector type in [policy-on-tag.md](policy-on-tag.md).
[...]
+| Column-level decisions | A tag on a
column does not affect decisions about its table.
[...]
+| A `scope` field in `content`, restricting a rule to a subtree | Not a
security boundary: creating a policy already needs metalake-wide
`CREATE_POLICY`, so whoever writes the rule chooses its reach anyway. It is
also not checked when a tag is applied, so it limits where a rule takes effect
rather than stopping a wrong tag. One tag meaning different things in different
subtrees is already covered by tag assignment values with a value-sensitive
selector. Can be added later, since an absen [...]
+| Audit of tag-conferred access, and dry-run | Nothing
records that a tag granted access — the authorizer emits an event on denial,
not on allow. A dry-run mode previewing what enabling the flag would change
needs that same record. Both can follow. |
+| Replacing RBAC | Baseline
privileges, ownership and traversal are unchanged. See [Composition with
RBAC](#composition-with-rbac).
[...]
+
+---
+
+## Alternatives considered
+
+| Option |
Pros
| Cons
| Status |
+| ---------------------------------------------------------------------- |
----------------------------------------------------------------------------------------------------------------
|
----------------------------------------------------------------------------------------------------------------------------
| ------------ |
+| **A `system_access_control` policy type bound to a tag** |
Reuses the entity, relation, selector and resolver; no new REST or client
surface; one governance model to learn | The role condition lives in `content`
JSON, so lookup by role needs a derived index rather than a foreign key
| **Decided** |
+| A dedicated `tag_access_policy` entity with action and role as columns |
Foreign key on role; indexed lookup; cascade on role deletion falls out of the
schema | New table across three dialects, new REST
resource, new client and CLI surface, a second governance model alongside
policies | Rejected |
+| Extend RBAC grants with a tag predicate | No
new concepts
| The grant table is object-identified; a
predicate has no object, and every grant read path would change
| Rejected |
+| Evaluate tags in an external engine (OPA and similar) |
Arbitrary policy language
| Moves the decision out of Gravitino,
duplicates the tag hierarchy, and cannot use the existing expression path
| Rejected |
+
+That one con means the server keeps the role reference consistent, rather than
the database schema
+doing it. [Lifecycle](#lifecycle) covers how.
+
+---
+
+## Model
+
+### Content
+
+`PolicyContent` is an interface, and each built-in policy type has a concrete
implementation with
+typed fields and a `validate()` that runs at write time.
`IcebergDataCompactionContent` is the
+existing example. `system_access_control` follows the same pattern with a new
+`AccessControlContent`:
+
+| Field | Type | Meaning
|
+| ------------------ | ------------------------ |
-------------------------------------------------------------------------------------------------------------------
|
+| `privileges` | list of `Privilege.Name` | The privileges the rule
confers. Each must be a permitted name — see [Permitted
privileges](#permitted-privileges). |
+| `applicableRoles` | list of role names | The **condition**. Satisfied
when any listed role is among the caller's active roles.
|
+
+Active roles are the roles the caller activated for the request, and default
to every role they hold
+when the request does not narrow. [Evaluation](#evaluation) explains why the
tag check has to apply
+that narrowing itself.
+
+`validate()` rejects at creation rather than at evaluation:
+
+- `privileges` is non-empty and every entry parses to a permitted
`Privilege.Name`.
+- `applicableRoles` is non-empty and every name is non-blank.
+
+Rejecting at write time matters because the alternative failure is silent: a
policy naming a
+privilege that does not parse simply grants nothing, and nothing surfaces
until someone notices
+the access they expected is missing.
+
+Whether `validate()` also requires the named role to *exist* is part of
+[OQ-3](#oq-3--deleting-a-referenced-role), not a separate decision.
+
+### Permitted privileges
+
+Parsing to a `Privilege.Name` is a syntax check, not a safety one. A rule may
confer only
+privileges that grant access to the tagged object itself, so `validate()`
checks each name against
+a fixed allowlist:
+
+| Object | Permitted |
+| -------- | -------------------------------------------------- |
+| Table | `SELECT_TABLE`, `MODIFY_TABLE`, `PROBE_TABLE_LIKE` |
+| View | `SELECT_VIEW` |
+| Fileset | `READ_FILESET`, `WRITE_FILESET` |
+| Topic | `CONSUME_TOPIC`, `PRODUCE_TOPIC` |
+| Model | `USE_MODEL` |
+| Function | `EXECUTE_FUNCTION` |
+
+`PROBE_TABLE_LIKE` is the only entry that is not data access — it confers the
ability to probe
+whether a table-like object exists. It is in because a rule conferring
`SELECT_TABLE` on an object
+already reveals that the object exists; withholding the probe would leave a
caller able to read a
+table they cannot confirm is there.
+
+An allowlist rather than a denylist so the boundary fails closed: a privilege
added to
+`Privilege.Name` later confers nothing through a tag until someone adds it
here deliberately.
+
+Everything else is rejected. Two classes are worth naming because the reasons
differ:
+
+- **Authority over the authorization system** — `MANAGE_USERS`,
`MANAGE_GROUPS`, `MANAGE_GRANTS`,
+ `CREATE_ROLE`, `CREATE_TAG`, `APPLY_TAG`, `CREATE_POLICY`, `APPLY_POLICY`.
These turn one tagging
+ operation into a standing ability to widen access. `MANAGE_GRANTS` binds to
every taggable type
+ and covers all children of whatever it binds to, so a tag carrying it on one
catalog would let
+ every role in `applicableRoles` grant anything beneath that catalog — and
would keep doing so
+ after the applier's own authority was revoked. `APPLY_TAG` and
`APPLY_POLICY` close the loop
+ further, letting a conferred role extend the tag system's own reach.
+- **Traversal** — `USE_CATALOG` and `USE_SCHEMA`, for the reasons in
+ [Traversal stays RBAC](#traversal-stays-rbac).
+
+Both exclusions are the same argument: a tag confers access to data, never the
ability to hand out
+access or to reach new territory.
[OQ-4](#oq-4--authority-to-confer-access-through-a-tag) governs
+who may apply a rule and does not substitute for this, because the applier
holds the authority
+privilege by construction — the check they pass is exactly the one the rule
would make permanent.
+
+### Credential vending
+
+A tag-conferred privilege does reach storage. `GET
.../objects/{type}/{fullName}/credentials` is
+gated by `CAN_ACCESS_METADATA`, which `SELECT_TABLE` or `READ_FILESET`
satisfies, so a caller
+holding one only through a tag can obtain a storage credential for that object.
+
+That is intended rather than a leak, and it is why the allowlist admits only
data privileges. The
+same privilege granted through a role vends the same credential; a tag changes
the route, not the
+reach.
+
+Writes work the same way. Whether a read or a write credential is issued is
decided by a privilege
+check of its own: the Iceberg REST server uses
+`ANY(OWNER, METALAKE, CATALOG, SCHEMA, TABLE) || ANY_MODIFY_TABLE`, and the
generic credential
+endpoint uses `ANY(OWNER, METALAKE, CATALOG, SCHEMA, FILESET) ||
ANY_WRITE_FILESET`. Both
+`MODIFY_TABLE` and `WRITE_FILESET` are allowlisted, so a tag that confers one
yields a write
+credential, as the same privilege granted through a role would.
+
+What a tag cannot reach is the secrets endpoint. `getSecrets` requires
`USE_SECRETS`, which is not
+on the allowlist, so JDBC passwords stay out of reach. Storage keys are
different: the credentials
+endpoint is reachable through a tag, and what it hands back depends on the
catalog's provider.
+
+How far a credential reaches once issued, and for how long, is the catalog's
credential provider's
+business. A token provider scopes it to the object's location and expires it;
a static secret-key
+provider hands out the catalog's key pair, which never expires. A tag does not
change that either
+way.
+
+The design does not change a credential once issued — see
+[Enabling the feature](#enabling-the-feature) — and it does not reach an
engine that holds its own
+storage credentials and never asks Gravitino for one.
+
+### `applicableRoles` is a condition, not a principal
+
+The rule does not grant anything to `analyst`. It states that *if* the caller
holds `analyst`
+among their active roles *and* the object carries `certified`, then
`SELECT_TABLE` is satisfied
+for this request.
+
+The distinction matters for two reasons. The rule is not a grant, so it does
not appear in the
+role's securable objects and does not participate in grant listing. And a role
that is never
+assigned to anyone confers nothing, exactly as an unassigned role does today.
+
+### The tag bind
+
+The policy is attached to the tag through the existing policy-to-tag relation
and its selector,
+exactly as governance policies are. `ALL_VALUES` in the example above matches
the tag regardless of
+assignment value; value-sensitive selectors work as they do for governance
policies, and nothing in
+this design is specific to `ALL_VALUES`.
+
+---
+
+## Evaluation
+
+An authorization decision needs to know, for the object being accessed and the
caller's roles,
+whether any access rule is satisfied. That requires three things:
+
+1. the tags effective at the object after nearest-wins resolution
+ ([tag-assignment-values.md](tag-assignment-values.md)), including those
inherited from ancestors;
+2. the `system_access_control` policies bound to those tags;
+3. for each, whether any of `applicableRoles` is among the caller's active
roles.
+
+Access rules only allow — `content` has a role condition but no deny effect —
so a tag cannot
+restrict or deny. An RBAC `DENY` is unaffected; see [Allow and
deny](#allow-and-deny).
+
+The question is *where* steps 1 and 2 happen.
+
+### Decided: check tags at the privilege leaf
+
+Every privilege check bottoms out in `GravitinoAuthorizer.authorize`. The
expression converter
+expands each `ANY_*` macro mechanically —
+
+```
+ANY_USE_CATALOG → ANY(USE_CATALOG, METALAKE, CATALOG) &&
!ANY(DENY_USE_CATALOG, METALAKE, CATALOG)
+```
+
+— and `hasAuthorizeWithoutDeny` walks the object's ancestor chain calling
`authorize` and `deny` at
+each level. Tag evaluation goes inside `authorize`: when the RBAC rows do not
allow, resolve the
+effective tags of the object being decided, load the access policies bound to
them, and test those
+against the caller's roles.
+
+Three properties follow from the surrounding code rather than from a rule this
design has to write:
+
+- **RBAC deny still wins.** The `!ANY(DENY_…)` conjunct is built from `deny`,
which the tag path
+ never touches, so an allow-only tag cannot reach it. See
+ [OQ-2](#oq-2--composition-when-a-tag-allows-and-rbac-denies).
+- **Traversal stays RBAC.** Each conjunct consults tags independently, so a
tag granting
+ `SELECT_TABLE` still cannot bypass `USE_CATALOG`.
+- **A denial stays attributable.** The tag check is a distinct step, so the
information needed to
+ explain a decision stays separable from the grant that would otherwise have
produced it.
+
+One rule does not come for free. Role assumption narrows a request to the
roles the caller
+activated, but that narrowing lives inside `enforceNarrowed`, on the jCasbin
path the tag check
+does not take. The tag check therefore applies it itself: `applicableRoles` is
tested against the
+caller's *active* roles, and an `ActiveRoles.none()` request grants nothing.
Otherwise a caller who
+narrowed would silently keep tag-derived access they had asked to drop.
+
+Inheritance is the one thing the walk does not supply. `authorize` resolves
the object's effective
+tags once ([tag-assignment-values.md](tag-assignment-values.md)) rather than
asking each level in
+turn. Different tag names still union down the chain; nearest-wins settles
only the same name
+assigned at two levels, where the nearer assignment wins and the farther one
is dropped:
+
+```
+catalog lakehouse certified = gold pii = true
+table finance.orders certified = bronze
+
+effective on the table certified = bronze pii = true
+```
+
+`pii` is inherited; `certified=gold` is gone because the table overrode it. So
a rule bound with
+`TAG_VALUE("gold")` does not match, while asking level by level would still
find `gold` on the
+catalog and grant. The two readings agree under `ALL_VALUES`, where only the
presence of the name
+matters, and diverge as soon as a rule reads the value.
+
+One constraint on where the check hooks in. `hasAuthorizeWithoutDeny` walks
the ancestor chain
+calling `authorize` at each level, so a check placed inside that per-level
call would resolve
+effective tags once per level, each resolution walking its own chain —
quadratic in chain depth,
+and for nothing, since the leaf's effective tags already subsume every
ancestor's. The check runs
+once for the object under decision, not once per level of the RBAC walk.
+
+The cost lands on the request path, and is set out in [Cost](#cost).
+
+### Decision flow
+
+The following flow describes an ordinary tag-conferrable privilege check.
Existing ownership
+branches and the enclosing authorization expression remain responsible for the
complete endpoint
+decision, including RBAC-only traversal checks.
+
+```mermaid
+flowchart TD
+ A[Privilege check] --> B{Tag access enabled?}
+ B -->|No| C[Existing RBAC decision]
+ B -->|Yes| D{RBAC allows without an applicable deny?}
+ D -->|Yes| E[Privilege satisfied]
+ D -->|No| F[Resolve effective tags and enabled access policies once per
object]
+ F --> G{Selector, privilege and active role match?}
+ G -->|No| H[Privilege not satisfied]
+ G -->|Yes| I{Explicit RBAC deny applies?}
+ I -->|No| E
+ I -->|Yes| J[Privilege not satisfied; record suppressed tag allow]
+```
+
+This combines both sources of authority without requiring both to run on every
request. Tags
+cannot revoke an RBAC allow, so skipping them after a successful RBAC decision
preserves the
+result. An RBAC miss is not an explicit deny: it gives tags an opportunity to
grant access. An
+explicit deny remains effective even when a tag matches. This fast path
depends on v1 being
+allow-only; adding restrictive tag policies would require revisiting it.
+
+### Independently testable evaluation
+
+M3 separates the evaluation into three components with explicit inputs:
+
+- **Resolution:** load tag assignments and policy bindings through an
injectable reader, and
+ resolve nearest-wins inheritance. Point checks and list preloads produce the
same resolved
+ representation. Tests can supply an in-memory hierarchy and bindings without
a catalog or
+ database.
+- **Matching:** a deterministic evaluator takes the requested privilege,
active role set and
+ resolved tags and enabled policies. It returns a match or no match, with the
matching tag and
+ policy identifiers. It performs no storage reads, role expansion or cache
mutation and has no
+ dependency on REST or jCasbin.
+- **Composition:** the authorization adapter combines the match with the
existing RBAC allow,
+ deny and traversal checks. It also records decision provenance. Storage
failures are errors,
+ not empty policy sets or successful matches; an evaluation error cannot
grant tag-derived
+ access.
+
+The matching result is internal evidence, not a new public authorization
decision or API. Keeping
+it separate lets tests assert both the decision and its explanation without
starting the server.
+
+M3 is complete only with unit tests for the resolver, matcher and composition
adapter, plus
+integration tests through the authorization-expression and list-filtering
paths. Cover:
+
+- multiple privileges and any matching active role; no match for inactive
roles or
+ `ActiveRoles.none()`;
+- same-name nearest-wins overrides, different-name inheritance, `ALL_VALUES`
and value-sensitive
+ selectors, disabled policies and absent bindings;
+- RBAC allow, RBAC miss, explicit deny with a matching tag, missing traversal
privileges, and the
+ feature disabled; a skipped tag path must perform no tag-resolution reads;
+- resolution failures, attribution of a suppressed tag allow, and identical
point/list decisions
+ for the same caller, object and privilege;
+- one resolution per object per request despite repeated privilege checks,
including an ancestor
+ value overridden at the target; the RBAC ancestor walk must not restore the
overridden value.
+
+M4 adds multi-node revocation and freshness tests. M5 adds query-count tests
and list benchmarks
+across candidate counts, hierarchy depths and tag counts. Record the RBAC-only
baseline and
+feature-enabled latency and query counts so later changes to the model can be
checked against
+both correctness and cost.
+
+### Rejected: expand tag rows when roles load
+
+The alternative writes one permission row per (role, object) when a role's
policies load, so tag
+permissions are indistinguishable from RBAC ones at decision time.
+
+It fails on cardinality. The jCasbin matcher compares `metadataId` for
equality with no prefix
+form, so a tag on a catalog grants on a table only if a row exists for that
table. Tagging one
+catalog materialises a row per descendant per affected role, and every later
`CREATE TABLE` beneath
+it has to add rows — a write-path dependency on the authorizer that does not
exist today. Miss one
+and stale rows keep granting, which errs towards more access rather than less.
+
+### Freshness
+
+Tag state is not cached across requests. The evaluator reaches both effective
tags and the policies
+bound to them through `listEntitiesByRelation`, which returns
`backend.listEntitiesByRelation(...)`
+with nothing in front of it; `RelationalEntityStore` caches entities, and its
own comment records
+that relation query results are not among them. Every request reads current
state, so no transport
+is needed to propagate a change:
+
+| Change | Reaches another node
|
+| -------------------------------------------- |
-------------------------------------------------------------------------- |
+| Tag applied to or removed from an object | Next request
|
+| Policy bound to or unbound from a tag | Next request
|
+| Policy created, altered, disabled or dropped | Next request
|
+| Role membership changed |
`gravitino.authorization.jcasbin.cacheExpirationSecs`, one hour by default |
+
+Next request means one whose authorization begins after the mutation commits.
+
+Only the last is bounded, and it is the existing RBAC bound: roles reach the
evaluator through the
+jCasbin cache whether or not a tag is involved. `loadedRoles` version-checks
`role_meta.updated_at`
+on read and `groupRoleCache` checks `group_meta.updated_at`, with TTL bounding
the rest. This
+design does not change any of it.
+
+Freshness is paid for on every request, in the queries counted in
[Cost](#cost). That is the
+trade. Caching tag state across requests would invert it, and is what needs a
transport that does
+not exist today: relation changes emitted into `entity_change_log`, or a poll
of the relation
+tables. The rejected option needs the same signals for correctness rather than
for speed, since it
+materialises rows that must be rewritten.
+
+See [OQ-1](#oq-1--where-tags-are-evaluated).
+
+### List filtering
+
+List endpoints filter their results through the same authorizer, so
tag-derived permissions must be
+visible to filtering as well as to single-object checks.
+
+Filtering has a fast path today: `allVisibleViaParentScope` skips the
per-object loop entirely when
+a parent-scope grant makes every candidate visible and no object-level deny
exists. Tags only add
+access, so that short-circuit stays correct untouched — if RBAC already shows
everything, no tag
+can change the answer.
+
+When it misses, the per-object loop runs, and that loop is deliberately free
of per-object queries:
+`preloadToCache` batch-gets the entities and `preloadOwner` batch-gets their
owners, leaving the
+loop as in-memory evaluation. Resolving tags per candidate breaks that
property and turns one
+listing into N walks of the ancestor chain. Filtering needs a batch preload of
tag and policy state
+for the candidate set, alongside those two paths — not as an optimisation, but
to keep an invariant
+the loop already has.
+
+### Cost
+
+The feature is off by default, and while off the authorizer does not consult
tags at all, so a
+deployment that leaves it off pays nothing and the rest of this section does
not apply to it. See
+[Enabling the feature](#enabling-the-feature).
+
+With it on, tags are consulted only when the RBAC rows do not already allow,
so a check RBAC grants
+costs nothing. The cost falls on the not-allowed branch — which is the common
branch for list
+filtering, where most candidates are objects the caller cannot see. The
feature therefore makes
+"no" more expensive than "yes", inverting the shape RBAC has today.
+
+On that branch, one check resolves the object's effective tags, then loads the
policies bound to
+them:
+
+| Step | Cost today
|
+| ----------------------------------- |
----------------------------------------------------------------------------------------------------------------------------------------------
|
+| Resolve the object's effective tags | One relation query per level of the
ancestor chain. Nothing caches the result — `RelationalEntityStore` caches
entities, not relation queries. |
+| Load the policies bound to each tag | One `POLICY_METADATA_OBJECT_REL` query
per distinct effective tag.
|
+| Test `applicableRoles` | In memory, against roles the request
has already loaded.
|
+
+The chain is not bounded by a constant. `getParentMetadataObjects` expands a
hierarchical schema
+one level at a time, so a table under `catalog.a:b` walks four levels, and a
deeper schema walks
+more:
+
+```
+TABLE:catalog.a:b.table -> SCHEMA:catalog.a:b -> SCHEMA:catalog.a ->
CATALOG:catalog
+```
+
+Two costs the query count hides. `TagManager.listTagsInfoForMetadataObject` and
+`PolicyManager.listPolicyInfosForMetadataObject` each take a tree read lock
and call
+`checkMetadataObject`, whose existence check can reach the underlying catalog
— so the evaluator
+has to query the relation directly rather than reuse them. And
`batchListEntitiesByRelation`
+implements only `OWNER_REL` today, so the batch preload below is a new query,
not a reuse.
+
+There is also a write-side cost that predates this design: applying or
removing a tag invalidates
+the entity cache for both endpoints, and that invalidation cascades down the
identifier hierarchy,
+so tagging a catalog drops every cached schema and table beneath it.
+
+Three things get worse when the feature is on. The second is a trade-off the
feature asks for
+rather than a defect, but it should be a deliberate choice rather than a
surprise:
+
+- **Listings that miss the parent-scope short-circuit.** The per-object loop
issues no per-object
+ queries today, provided the preload runs — `preloadToCache` returns early
when the entity cache
+ is disabled or the type is not preloadable. Each not-allowed candidate adds
a chain walk plus a
+ policy query per tag, so a listing of N objects goes from a batch preload
and in-memory
+ evaluation to O(N × d) queries against the relational store. Latency on a
single call is not the
+ concern; connection-pool pressure under concurrency is.
+- **Granting through tags instead of coarse RBAC makes that miss more often.**
The short-circuit
+ fires on a parent-scope RBAC grant. A deployment that replaces those grants
with tag-derived
+ access removes the condition the short-circuit tests, so listings that are
free today take the
+ slow path.
+- **Tag churn degrades RBAC.** The invalidation above is not scoped to the tag
path — the entity
+ cache is shared, so retagging a catalog costs every request that reads
entities beneath it,
+ including requests that never touch a tag.
+
+Two guards keep the not-allowed branch cheap where no tag could grant anyway:
no enabled
+`system_access_control` policy in the metalake, resolved once per request; and
no active roles on
+the request, since `applicableRoles` is tested against active roles and an
empty set matches
+nothing. The first has no list-by-type query today, so it lists the metalake's
policies.
+
+Three mitigations, in increasing order of what they cost to build:
+
+- **Per-request memoisation.** `AuthorizationRequestContext` already memoises
the final decision,
+ but keys it on principal, metalake, object and privilege, while the
expensive part — effective
+ tags and the policies bound to them — depends on neither the principal nor
the privilege.
+ Memoising that per object keeps a request checking several privileges on one
object to a single
+ walk.
+- **Batch preload for lists.** Resolve the candidate set's tag and policy
state in one round trip
+ rather than N walks, as above.
+- **A cache across requests.** Not designed here, and not yet designable:
caching tag state beyond
+ one request needs an invalidation signal, and relation changes have no
carrier today — see
+ [Freshness](#freshness). Performance and freshness are the same problem, and
v1 buys freshness.
+
+The first two land in M3 and M5. The third needs a transport that does not
exist; until it does,
+the uncached cost above is what the feature costs.
+
+---
+
+## Composition with RBAC
+
+### Traversal stays RBAC
+
+`USE_CATALOG` and `USE_SCHEMA` are not conferrable by a tag. Reaching
+`lakehouse.finance.orders` requires:
+
+```
+USE_CATALOG on lakehouse from RBAC
+USE_SCHEMA on lakehouse.finance from RBAC
+SELECT_TABLE on the table from RBAC or from a tag rule
+```
+
+**Tag-based access is additive within territory a role already has, not a way
to hand out new
+territory.** For `analyst` to read `lakehouse.finance.orders` through
`certified`, the role must
+already hold `USE_CATALOG` on `lakehouse` and `USE_SCHEMA` on `finance`. A tag
applied to a table in
+a schema the role cannot enter has no effect. `validate()` rejects both names
in `privileges`.
+
+That containment is what makes the worst case analyzable. The most a
misapplied tag can do is
+expose an object the role could already traverse to, which scopes the blast
radius to the
+territory its RBAC grants already describe rather than to the whole metalake.
It bounds what the
+object reaches on the metadata plane; what a storage credential for that
object reaches is a
+separate question — see [Credential vending](#credential-vending).
+
+### Allow and deny
+
+With `ALLOW` only, two access rules cannot conflict — they union. The
interaction that remains is
+between a tag rule that allows and an RBAC grant that denies, and the deny
wins. See
+[OQ-2](#oq-2--composition-when-a-tag-allows-and-rbac-denies).
+
+---
+
+## Administration
+
+Four write paths can change who has access. Their current authority
requirements:
+
+| Operation | Expression today
| Scoping available
|
+| ------------------------ |
-------------------------------------------------------------------------------
| ---------------------------------------------------------------- |
+| Create a policy | `METALAKE::OWNER \|\| METALAKE::CREATE_POLICY`
| metalake only
|
+| Create a tag | `METALAKE::OWNER \|\| METALAKE::CREATE_TAG`
| metalake only
|
+| Bind a policy to a tag | `METALAKE::OWNER \|\| ((TAG::OWNER \|\|
ANY_APPLY_TAG) && (POLICY::OWNER \|\| ANY_APPLY_POLICY))` | metalake, the
specific tag, or the specific policy |
+| Apply a tag to an object | `METALAKE::OWNER \|\| ((TAG::OWNER \|\|
ANY_APPLY_TAG) && CAN_ACCESS_METADATA)` | the specific tag, and only objects
the caller can already access |
+
+Creating a policy and creating a tag are both metalake-wide, with no way to
scope either to a
+catalog. Authoring access rules is therefore a central function today;
delegating it per-catalog
+would need new privileges.
+
+`ANY_APPLY_TAG` above expands to
+`(METALAKE::APPLY_TAG || TAG::APPLY_TAG) && !(METALAKE::DENY_APPLY_TAG ||
TAG::DENY_APPLY_TAG)`.
+
+Applying a tag is the one operation that is already bounded on the object side.
+`CAN_ACCESS_METADATA` resolves per entity type to that type's load expression;
for a table:
+
+```
+ANY(OWNER, METALAKE, CATALOG) ||
+SCHEMA_OWNER_WITH_USE_CATALOG ||
+ANY_USE_CATALOG && ANY_USE_SCHEMA && (TABLE::OWNER || ANY_SELECT_TABLE ||
ANY_MODIFY_TABLE)
+```
+
+So an applier must already own the object or an ancestor, or hold traversal
plus read or write on
+the object itself. That bounds which objects they can tag, not what the tag
may confer on them.
+
+### What binding a policy to a tag delegates
+
+Binding an access policy to a tag is a deliberate delegation: it says that
whoever can apply this
+tag may confer this access on the named role. That is the feature, not a
defect.
+
+Three properties of that delegation are worth recording:
+
+- `CAN_ACCESS_METADATA` establishes that the applier can *access* the object.
It does not establish
+ that they may *confer* access on a role they do not control. These are
different authorities.
+- `ApplyTag.canBindTo` accepts only `METALAKE` and `TAG`, so the delegation
cannot be scoped to a
+ subtree — "may apply `certified` within `lakehouse.finance`" is not
expressible.
+- The policy half of the bind expression is satisfied by `POLICY::OWNER` or a
policy-scoped
+ `APPLY_POLICY`. Whoever owns the policy can therefore bind it to a tag that
is already applied
+ anywhere, holding no authority over the objects that tag reaches.
+
+See [OQ-4](#oq-4--authority-to-confer-access-through-a-tag).
+
+### Enabling the feature
+
+One server-level configuration turns tag-based access on or off. It defaults
to off. No dry-run or
+audit-only mode is in scope for v1.
+
+**Off.** Policies and tags behave as they do today: they can be created, bound
to each other and
+applied to objects, each still requiring the authority in
+[OQ-4](#oq-4--authority-to-confer-access-through-a-tag). The authorizer never
reads them, so no tag
+grants anyone anything.
+
+**On.** The authorizer consults tags — both when deciding access to a single
object and when
+filtering a list. It is both or neither: enforcing only one would either grant
a caller access to
+objects that never appear in their listings, or list objects they are then
denied.
+
+A metalake with no `system_access_control` policy already confers nothing, so
the flag is not what
+makes the feature opt-in. It is a kill switch. This adds a new path to the
authorization hot path,
+and an operator who needs it gone — a wrong decision, or list filtering
degrading under
+[Cost](#cost) — should not have to unbind policies one at a time to get there.
+
+**On to off revokes.** Access held only through a tag stops being granted.
Removing a tag or
+unbinding a policy revokes the same way. Four cases:
+
+| Case | Bound
|
+| ----------------------------------- |
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
+| A new request, on any node | None, for removing a tag or unbinding
a policy — see [Freshness](#freshness).
|
+| The flag itself, on another node | The flag is read at startup, so a
rolling restart is the bound. Every server configuration behaves this way.
|
+| An operation already admitted | Runs to completion. The check happens
once, when the request is authorized.
|
+| A storage credential already vended | Until it expires, and nothing in
Gravitino recalls one. A static secret-key credential never expires, so there
only rotating the catalog's key ends access — see [Credential
vending](#credential-vending). |
+
+Only the last outlives the decision that granted it, and it is the one to plan
for. The other three
+are how every privilege in Gravitino already behaves. M4 tests the first row
across nodes.
+
+**Off to on grants everything authored while it was off.** The authority
checks ran when each
+policy was bound, so that access was authorized. The flag decides when it
takes effect, not
+whether it was allowed. That holds from M2 onwards; binds made during M1
predate those checks,
+so M2 validates what already exists.
+
+The flag is server-wide, so turning it off affects every metalake.
+
+---
+
+## Lifecycle
+
+### Finding the policies that reference a role
+
+Role names live inside `content`, a JSON column. Answering "which policies
reference `analyst`"
+by scanning and parsing every policy in the metalake does not scale, and role
deletion needs that
+answer.
+
+The server therefore also writes an indexed join row per referenced role, in
the same shape as
+the existing `tag_relation_meta` and `policy_relation_meta` tables. The record
is **derived** — the
+server computes it from `content` — and **not user-writable**: no endpoint
touches it. If it ever
+disagrees with `content`, `content` is authoritative and the record is rebuilt.
+
+The "not user-writable" part is what matters. If the record could be written
directly there would
+be two answers to which roles a policy names, and the evaluator would have to
pick one.
+
+The record carries role names, since that is what `content` holds.
+
+### Deleting a role
+
+The derived record makes the affected policies findable. What should happen to
them is
+[OQ-3](#oq-3--deleting-a-referenced-role).
+
+### Deleting a policy or unbinding it from a tag
+
+The rule stops applying immediately, subject to the freshness question above.
Both are existing
+policy operations; nothing is added.
+
+### Events
+
+Policy creation, update and deletion already emit events. Access policies emit
the same events with
+no additional payload. The bind and unbind operations emit the existing
policy-to-tag events.
+
+Nothing is emitted when a tag confers access. The authorizer emits an event on
denial only, and
+that is unchanged here — see [Not in this version](#not-in-this-version).
+
+---
+
+## Prior art
+
+Three systems solve this problem, and the shape proposed here matches them.
The table records only
+what current vendor documentation states; `—` means not verified rather than
absent.
+
+| | Apache Ranger
| AWS Lake Formation
| Databricks Unity Catalog |
+| ------------------------ |
------------------------------------------------------------------------------------
| -------------------------------------------------------- |
----------------------------------------------------- |
+| Feature | Tag-based policies
| LF-TBAC
| ABAC `GRANT` policies |
+| Rule attaches to | A tag, within a tag service
| An LF-Tag expression
| A catalog or schema, with a tag condition |
+| Evaluated | At request time; `RangerTagEnricher` adds the
resource's tags to the request context | At request time, against the
resource's tags | At request time, on each access attempt
|
+| Inheritance | From the tag source
| Table from database, column from table;
override allowed | From parent catalog or schema; override allowed |
+| Can a tag rule deny? | Yes
| No — grant only
| No — adds access only |
+| Authority to apply a tag | —
| A distinct grant to assign LF-Tags
| `ASSIGN` on the tag **and** `APPLY TAG` on the object |
+
+Four points of agreement, each corresponding to a decision made above:
+
+- **Rules attach to a tag, not to the object.** The premise of the feature.
+- **Tags are resolved on the request path**, not pre-expanded into a grant per
object. No system
+ surveyed does the expansion, which is the option [Evaluation](#evaluation)
rejects.
+- **Tags inherit down the hierarchy**, and both Lake Formation and Unity
Catalog let a nearer
+ assignment override an inherited one — the nearest-wins rule this design
already assumes
+ ([tag-assignment-values.md](tag-assignment-values.md)).
+- **Applying a tag is authority in its own right.** Unity Catalog requires a
tag permission *and*
+ an object permission, which is the two-clause structure proposed in
+ [OQ-4](#oq-4--authority-to-confer-access-through-a-tag). Databricks gives
the reason directly:
+ if a user can change tags on an asset, they can change which policies apply
to it.
+
+Two divergences are worth naming.
+
+Ranger tag policies can deny; the policies here cannot. Lake Formation and
Unity Catalog are both
+allow-only, and Databricks states that GRANT policies cannot revoke access
granted directly, so the
+restriction in [Allow and deny](#allow-and-deny) is the majority position
rather than an unusual
+one.
+
+Unity Catalog does raise an error where policies conflict, but only when two
distinct row filters
+apply to the same table for a user, or two distinct masks apply to the same
column — transformations
+of the same kind, with no principled order between them. Its privilege
composition is additive. This
+design has no masking, and allow against deny has a principled order: deny
wins. So the error is not
+precedent here — see
[OQ-2](#oq-2--composition-when-a-tag-allows-and-rbac-denies).
+
+Ranger is also the only one of the three that documents an answer to
[Freshness](#freshness): the
+plugin caches tags locally, polls the tag store for changes, and falls back to
the cache file when
+the store is unreachable. It buys speed with a staleness window. This design
makes the opposite
+trade for v1 — read per request, no window — and Ranger is the reference for
what the other side
+of it costs to build.
+
+---
+
+## Decisions
+
+Each is settled below. The alternatives are kept with the reason they were not
taken.
+
+| | Question | Discussed
in | Decision
|
+| ---- | ------------------------------------------------------- |
------------------------------------------------------------- |
-------------------------------------------------------- |
+| OQ-1 | Where tags are evaluated |
[Evaluation](#evaluation) | Inside
`authorize`, at the privilege leaf |
+| OQ-2 | Composition when a tag allows and RBAC denies |
[below](#oq-2--composition-when-a-tag-allows-and-rbac-denies) | Deny wins
|
+| OQ-3 | What happens when a referenced role is deleted |
[below](#oq-3--deleting-a-referenced-role) | Refuse the
deletion |
+| OQ-4 | What authority conferring access through a tag requires |
[below](#oq-4--authority-to-confer-access-through-a-tag) | Grant authority
on the object and an explicit tag grant to apply; metalake-level policy
authority to bind or widen |
+
+### OQ-1 — where tags are evaluated
+
+Both placements and the reasoning are set out in [Evaluation](#evaluation).
Checking at the leaf
+inherits deny and traversal from the surrounding code, keeps a denial
attributable, and reaches
+inherited tags through one nearest-wins resolution per object; expanding rows
at load time reuses
+the permission engine but, because the matcher compares ids for equality,
needs a row per
+descendant object and a write-path dependency to maintain them.
+
+The leaf check also settles freshness rather than deferring it to a transport.
It reads tag state
+per request, so there is nothing to invalidate and no staleness window to
bound — see
+[Freshness](#freshness). The row-expansion option cannot be built until that
transport exists,
+which is a second reason not to take it.
+
+### OQ-2 — composition when a tag allows and RBAC denies
+
+The overlap in question is an explicit RBAC `DENY_*` row against a tag allow,
not the absence of an
+RBAC grant — absence is what sends the decision to tags in the first place.
+
+| | Option | Behaviour
|
+| ------------ | ------------------ |
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
+| **Decided** | Deny wins | An RBAC deny suppresses a tag allow
unconditionally. Predictable, matches the existing deny semantics, and a tag
can never be used to escape an explicit deny. Conflicts are invisible unless
surfaced separately as diagnostics. |
+| | Refuse the overlap | Treat allow-from-tag over deny-from-RBAC
as ambiguous and fail closed. Surfaces the conflict at the point it occurs, at
the cost of a third decision outcome the authorizer does not have today.
|
+
+Justification: deny wins for free under the placement proposed in
[Evaluation](#evaluation).
+`AuthorizationExpressionConverter` already expands every `ANY_*` macro to
`ANY(X) && !ANY(DENY_X)`,
+and a tag allow only feeds the allow side, so the RBAC deny still applies.
Refusing would deny the
+request too, so it changes no outcome — it only makes the conflict visible,
and the M3 diagnostic,
+which names the tag and policy whose allow the deny suppressed, does that
without adding a third
+decision state.
+
+The proposed conflict reporting is an internal diagnostic accompanying the
existing denial,
+not a new client-visible error type. It identifies the object, privilege,
matching tag and policy,
+and the explicit RBAC deny that suppressed the allow. A plain RBAC miss is not
a conflict.
+Detailed policy information stays in authorized server diagnostics rather than
being exposed to
+an unauthorized caller. The composition tests must verify this attribution
even when normal
+expression short-circuiting would otherwise skip the matching tag rule.
+
+This addresses the request to fail closed and make the competing rules
identifiable. v1 adds no
+distinct client-visible conflict error: the request is denied either way, and
telling an
+unauthorized caller which tag rule was suppressed leaks policy structure to
exactly the wrong
+audience.
+
+This relies on tag-conferrable privileges being reached through `ANY_*`
macros; a bare
+`TYPE::PRIVILEGE` has no deny conjunct. M3 carries a test to keep it that way.
+
+### OQ-3 — deleting a referenced role
+
+Only the policy is affected; the tag, the bind and the tagged objects are not.
This also settles
+whether `validate()` requires the role to exist at creation, since rejecting a
dangling reference
+at one end while allowing it at the other reaches the same state either way.
+
+| | Option | Behaviour
|
+| ------------ | ------------------------------- |
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
+| **Decided** | Refuse the deletion | The role cannot be deleted
while a policy still references it; the operator clears those references first.
No delete in Gravitino is blocked by a reference today, so this would be the
first. |
+| | Delete the referencing policies | No dangling state, but it
removes rules the operator may not have known existed. Acceptable only as an
explicit, confirmed cascade, which is out of scope here.
|
+| | Leave it dangling | Inert until a role of the
same name is created, which silently reactivates the rule against a different
population.
|
+| | Disable them | Deleting a role would then
also change policy state, conflating two operations that should stay separate.
|
+
+Justification: the operator deleting a role and the operator owning the
policies that reference
+it are often not the same person, so the deletion should stop rather than
silently change rules
+its author cannot see. Cascade stays available later as an explicit, confirmed
act.
+
+### OQ-4 — authority to confer access through a tag
+
+Three write paths confer access without going through a grant: applying a tag
that carries an
+access policy, binding an access policy to a tag that is already applied, and
widening a bound
+policy by adding a privilege or a role to its `content`. None of them checks
grant authority. A
+user holding only `SELECT_TABLE` can apply a tag that gives another role
`MODIFY_TABLE` — read
+access on an object becomes authority over who else can reach it.
+
+| | Option
| Behaviour
|
+| ------------ |
----------------------------------------------------------------------- |
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
+| **Decided** | Require grant authority on the object, and an explicit
`TAG::APPLY_TAG` | Applying an access-carrying tag also requires
`MANAGE_GRANTS` on the object or an ancestor, or ownership of it — the same
check `grantPrivilegeToRole` makes today. And a metalake-wide `APPLY_TAG` stops
reaching a tag once it confers access, so grants issued when tags were
descriptive do not silently widen. |
+| **Decided** | Require metalake-level policy authority to bind or widen an
access policy | Covers the other two paths: `METALAKE::OWNER` or
`METALAKE::CREATE_POLICY`. A policy-scoped `APPLY_POLICY` or `POLICY::OWNER` is
not enough, because a policy carries no scope of its own and binding it reaches
every object the tag already touches. Today a tag owner holding no policy
privileges can bind one, and everything already carrying the tag gains the
access at once. |
+| | Leave as is
| Reading an object is enough to change who else can reach it.
|
+| | Require the applier to hold the privilege the tag confers
| SQL `GRANT` semantics. Gravitino's own grant path does not work
this way — `MANAGE_GRANTS` lets an operator hand out privileges they do not
hold — so tags would become stricter than roles.
|
+| | Require authority over every object the tag currently reaches
| A bind that is legal today becomes illegal tomorrow because someone
applied the tag elsewhere, and the check costs an enumeration that grows with
the tag's reach.
|
+
+Justification: the two requirements cover different halves. Object authority
scopes where a tag
+reaches; policy authority scopes what it confers, and for an access-carrying
policy it is
+metalake-wide by construction. Because the halves are independent, no scope
has to be derived from
+the tag's current reach. Neither requirement adds a new privilege.
+
+Widening takes the same requirement as binding: the policy is already
attached, so adding a
+privilege or a role to `content` confers access across everything the tag
reaches with no further
+act by anyone. M2 covers all three paths.
+
+The cost is that an access policy cannot be delegated: a team owning a catalog
cannot own the
+policies that confer access within it. Scoped tags are the delegation path —
an access-carrying tag
+would carry a scope, could only be applied inside it, and policy authority at
that scope would then
+suffice. That needs `ApplyTag.canBindTo` extended to `CATALOG` and `SCHEMA`,
and rules for how
+tag-scoped and object-scoped `APPLY_TAG` grants compose while existing grants
retain their meaning.
+Both are deferred beyond v1, and neither is a prerequisite for M2: the
explicit tag grant answers
+*which tag*, while existing `MANAGE_GRANTS` or ownership on the object or an
ancestor answers
+*where*. No new IAM role or privilege is introduced by the current proposal.
+
+---
+
+## Implementation milestones
+
+Each milestone names the decision it rests on.
+
+| Milestone | What lands
[...]
+| --------------------------------- |
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
[...]
+| M1 — model and storage | `AccessControlContent` and its
`validate()`, registered in `PolicyContents` and the content DTO, and the
derived policy-to-role record written on policy create and update. Policies can
be created, validated and bound to tags; nothing evaluates them yet.
[...]
+| M2 — authority on the write paths | The authority checks on the three write
paths in OQ-4: applying an access-carrying tag, binding a policy to a tag
already applied, and widening a bound policy's `content`, including binds and
tag applications made during M1, before these checks existed. Lands before M3
is switched on, or all three confer access unchecked.
[...]
+| M3 — enforcement, single node | The check at the privilege leaf
described in [Evaluation](#evaluation), with per-request caching, behind the
configuration in [Enabling the feature](#enabling-the-feature) — which lands
here, or there is no way to back the feature out. Tags now grant access,
correctly on one node: an edit to a tag or a policy takes effect on the next
request, as [Freshness](#freshness) defines it; the per-request cache does not
outlive the request that built it. Beca [...]
+| M4 — multi-node tests | Tests that a tag application, a policy
bind, an edit to a policy's `content` and a policy disable are each visible on
the next request on another node, and that the feature flag is per node. No new
transport: [Freshness](#freshness) shows tag state is read per request, so
there is nothing to propagate.
[...]
+| M5 — affordable list filtering | The batch preload in [List
filtering](#list-filtering). Filtering already consults tags from M3; this is
what stops it costing an ancestor walk per candidate, so enabling the feature
on a large metalake before M5 is correct but expensive.
[...]
+| M6 — lifecycle and documentation | Role deletion, the events in
[Events](#events), and the traversal requirement in [Composition with
RBAC](#composition-with-rbac).
[...]
+
+M2 and M6 are independent of where tags are evaluated. M3, M4 and M5 all
follow from the leaf check.