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.

Reply via email to