mchades commented on code in PR #13867: URL: https://github.com/apache/gravitino/pull/13867#discussion_r4293970777
########## design-docs/domain-design.md: ########## @@ -0,0 +1,325 @@ +<!-- + 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: Domain + +| Version | Author | Date | Status | +| :---- | :---- | :---- | :---- | +| 1.24 | Jerry Shao | 2026-10-16 | Draft | + +## Goals + +1. **Business grouping**: group assets from any catalog under named domains (e.g. "Finance") for discovery, browsing and agent retrieval. An asset can belong to several domains. +2. **Membership is tags**: a leaf asset is a member when it carries the domain's backing tag, directly or inherited from its catalog or schema. There is no separate membership store. +3. **Separate privileges**: domain privileges govern the `Domain` object; tag privileges govern membership. Membership grants no access to data. +4. **One level of subdomains**, each with one parent; a subdomain can be moved. +5. **Draft and published status**: a `DRAFT` domain is returned only to its owner and managers, the same on REST, MCP and any UI. +6. **Inherited membership**: tagging a catalog or schema makes the leaf objects under it members, through Gravitino's existing tag inheritance. + +## Non-Goals + +1. **Approval workflow**: adding members, attaching subdomains and publishing take effect at once; status is a manual switch. +2. **Data-access grants on domains**: a domain carries no data privileges such as `SELECT_TABLE` (see Grants and policies). +3. **Domain-level policies**: domains add no policy mechanism; policies keep following tags (see Grants and policies). +4. **Deeper or multi-parent hierarchies**: the storage allows them later without a data migration. +5. **Automatic membership**, glossary and metric designs, and the ABAC policy language. Glossary terms can become members once they are taggable and added to the member types. +6. **UI**: this design covers the server, REST APIs, clients and MCP tools. + +## Usage Scenario + +1. A steward with `MANAGE_DOMAIN` and `CREATE_TAG` on the metalake creates domain Finance with a new backing tag `finance`. The steward owns both. +2. The steward creates subdomain Trading under Finance with a new backing tag `trading`, and grants `APPLY_TAG` on tag `trading` to the trading table owners. +3. A table owner tags `cat.db.trades` with `trading`. The table is a member of Trading, and is listed under Finance as "via subdomain Trading". Tagging schema `cat.db` with `finance` would make every leaf object in it a member of Finance. +4. A user or agent with `USE_DOMAIN` on Finance (which also covers Trading) or on Trading browses it and gets the tables they can access. +5. Trading is moved under Markets. Its members are now listed under Markets; nothing is retagged. + +## Design + +### Data model + +``` +Domain { id, name, comment, status: DRAFT | PUBLISHED, properties, + backingTagId?, backingTagValue?, audit_info } +DomainRel { parentId, childId, audit_info } // one edge per subdomain +``` + +- Domains live under a metalake. A subdomain and its parent are in the same metalake. +- Domain names are unique within a metalake across both levels: top-level domains and subdomains share one namespace, so a domain is addressed by its name alone. Domain names follow the same constraints as tag names (up to 128 characters) and may contain `/` (see REST API, Encoding). +- A unique index on `DomainRel (child_id, deleted_at)` keeps one parent per domain. `parent` is not stored on `Domain`; the API derives it. Dropping the index and checks would allow multiple parents later without a data migration. +- The backing tag is stored on the domain row as the tag's ID and an optional value, with an index on `(metalake_id, backing_tag_id, backing_tag_value, deleted_at)` for the reverse lookup "which domain does this (tag, value) back". `DOMAIN` is not a taggable object type, so the tag code paths are unchanged (see Alternatives considered). +- The binding refers to the tag by ID, and matching compares tag IDs: if the tag is deleted, the ID stays on the row and matches nothing, and a new tag with the same name does not match it. +- Domains, their parent and their backing tag are not cached; every request reads them from the store, so all servers see a move or a backing-tag change at once. Caching them later needs a version bump of the affected domain rows and a changelog entry. + +### Backing tag + +Each domain has **at most one** backing tag: an existing tag, optionally with a value (tag values: apache/gravitino#12162). Creating a domain always sets one; a domain has none only after its backing tag is removed or deleted, until a new one is set. + +- **Explicit at creation**: every create request names the backing tag; there is no default name derived from the domain. The caller either + - creates a new tag: a tag without values (`NO_VALUE`) with the given name, owned by the caller, which needs `CREATE_TAG`. If a tag with that name exists, creation fails with 409 `TagAlreadyExistsException`; an existing tag is never adopted implicitly; or + - binds an existing (tag, value), such as `dept=finance`. The caller must own the tag or the metalake; a missing tag fails with 404. + + A create request without a backing tag, or one that creates a new tag and also gives a value, fails with 400. +- **Tag value rules apply as for any assignment**: a tag with a non-empty allowed-values list can only be bound with one of its values; a `NO_VALUE` tag only without a value. +- **No sharing**: following the tag assignment rules, a tag has either one binding without a value, or bindings with values where each value backs at most one domain; the two are never mixed. A conflicting binding fails with 409 `BackingTagAlreadyBoundException` (a new `AlreadyExistsException` subclass). The reverse lookup is therefore one domain per (tag, value). +- **Matching** follows the tag lookup semantics (`GET /tags/{tag}/objects`). A backing tag without a value matches every assignment of the tag, with or without values, like a lookup without `value`. A backing tag with a value matches assignments whose values contain it, exact and case-sensitive, like `?value=`; a valueless assignment never matches it. The same rule applies to member listing and domains of an object, always against the object's effective assignment. +- **Changing it**: `PUT /domains/{domain}/backingTag` replaces or removes it on the server, under the domain and tag row locks. A missing tag fails with 404, a value the tag does not allow with 400, a tag the caller does not own (nor the metalake) with 403, and a binding that conflicts with another domain with 409; setting the current tag is a no-op. Assets carrying only the old tag drop out; nothing is migrated. This endpoint is the only way to change a backing tag: `DOMAIN` is not taggable, so the generic tag association API rejects it. +- **Never renamed**: moving or renaming a domain does not rename its tag; the hierarchy lives in `DomainRel`. A renamed tag keeps backing the domain, since the binding is by tag ID. +- **Deleting the tag** behaves as for any tag: it is allowed, and the tag's assignments on assets are removed with it. Its domain is left with no backing tag (`backingTag` reads as empty) and no members until a new one is set; the membership cannot be recovered by recreating a tag with the same name. Only the tag deletion event records this. +- **Separate from domain privileges**: the domain owner and the tag owner are independent. Managing a domain grants no `APPLY_TAG` on its tag, and transferring the domain's ownership does not transfer the tag's. + +### Subdomains + +Every operation that sets a parent (create a subdomain, attach, move) checks: + +| # | Constraint | +| :---- | :---- | +| C1 | A domain cannot be its own parent | +| C2 | The parent is top-level | +| C3 | The attached domain has no subdomains | +| C4 | Parent and child are in the same metalake | +| C5 | At most one parent; attaching a domain that has one is a move | +| C6 | A `PUBLISHED` domain can only be attached under a `PUBLISHED` parent | + +C1–C3 keep the hierarchy one level deep and acyclic, so no cycle detection is needed. + +- **Attach / move** re-points the child's single edge in one transaction. Attaching to the current parent is a no-op. +- **Detach** removes the current edge; the child becomes top-level. +- **Delete** is refused while the domain has subdomains. Deleting removes the domain row and its own parent edge. The tag and the tags on assets stay. +- **Membership and scope**: membership comes from backing tags only (see Membership and inheritance), so an asset tagged only `trading` is a member of Trading, not of Finance. A top-level domain's *scope* is its own members plus the members of the subdomains the caller can see; a subdomain's scope is its own members. Member listing and domains of an object report scope, with the relation of each entry; policies follow tags only (see Grants and policies). + +### Locking + +Domain operations use the store's existing locking primitives, in one global order: the metalake fence (`LiveEndpointService.lockLiveEndpoint`, shared), then the domain rows involved in ID order, then tag rows (exclusive, as `lockTagsForAssignment` does). Each row is locked once, in its final mode, so no lock is upgraded inside a transaction. `DOMAIN` is added to `LiveEndpointSQLProvider`. + +| Operation | Rows locked | Conflicts it excludes | +| :---- | :---- | :---- | +| Create a top-level domain | metalake (shared) → existing tag (exclusive) | metalake drop; two domains binding the same (tag, value) | +| Create a subdomain | metalake (shared) → parent (shared) → existing tag (exclusive) | the parent being attached elsewhere (C2) | +| Attach / move | child (exclusive) and new parent (shared), in ID order | the new parent being attached elsewhere (C2); a subdomain being created under the child (C3); crossing moves | +| Detach | child (exclusive) | | +| Publish | domain (exclusive) and parent (shared) | the parent being unpublished | +| Unpublish, delete | domain (exclusive) | a subdomain being created, attached or published under it | +| Set or remove the backing tag | domain (exclusive) → tag (exclusive) | a conflicting binding; the tag being deleted | +| Rename, alter | domain (exclusive) | | + +- Every name-addressed operation resolves the name to an ID, locks the row, then re-checks that the row still has that name and is not deleted; otherwise it fails with 404. +- A tag named in a request is re-checked after locking; a missing tag fails with 404 instead of being skipped. +- A new backing tag has no row to lock yet; its name is protected by the tag name's unique key, so a concurrent create with the same name fails with 409. +- The unique index on `DomainRel` and the domain row's own version (optimistic locking, never exposed to callers) remain as backstops. + +### Status + +- New domains are `DRAFT`. The owner or a `MANAGE_DOMAIN` holder publishes or unpublishes, at any time and without approval. +- **Visibility is the same on every surface** (REST, MCP, UI). A `DRAFT` domain is returned only to its owner and to `MANAGE_DOMAIN` holders on it, on its parent or on the metalake. Domain APIs do not return it to anyone else: lists omit it, get returns 404, and it is not in `list_domains`, member listings, an object's domains or a parent's roll-up. Responses carry `status`, so a caller that sees drafts can filter them out. +- **Known channels**: hiding a draft is not confidentiality. Its existence can still show through: + - creating a domain with the same name fails with 409; + - its backing tag is an ordinary tag, visible to tag readers and on tagged assets (use a non-descriptive tag name if that matters); + - `parent` is always returned, even when the caller cannot see the parent. Only the name is exposed; getting the parent still returns 404, and nothing can be done on it. +- **Consistency**: publishing a subdomain needs a published parent; unpublishing needs no published subdomains (C6 covers attach and move). +- Assets can be tagged into a `DRAFT` domain to prepare it; anyone with `APPLY_TAG` on its tag can do so. +- Roll-ups depend on the caller: someone who can see a `DRAFT` subdomain sees its members under the parent; others do not. + +### Membership and inheritance + +- **Rule**: an object's effective tags are its own plus its schema's and catalog's; for the same tag the nearest level wins, and different tags accumulate. A leaf object (see Member types) is a member of domain D when its effective tags match D's backing (tag, value). Membership reuses core's `EffectiveTagResolver`, which applies no tag-privilege filtering: domain visibility and object access are applied afterwards, so a caller needs no tag privilege. +- **Different tags accumulate**: with schema `finance` and table `sales`, the table is in both Finance and Sales, and a child cannot leave a domain it inherits. +- **Values of one tag** backing different domains override each other: with schema `dept=finance` and table `dept=hr`, the table is only in the `dept=hr` domain, unless it uses `dept=[finance, hr]`. This is the one case where a child can leave an inherited domain, and only for bindings with a value; a binding without a value matches every value. +- **Member types**: leaf objects only: tables, views, filesets, topics, models and semantic models (`DomainMember.SUPPORTED_TYPES`). Catalogs and schemas are never members: one that carries a backing tag passes membership to the leaf objects under it through tag inheritance. Columns and functions are not members either: a column or function carrying a backing tag is not listed. A type that becomes taggable later joins only when it is added to this list. Metrics belong through their semantic model. +- **Relations**: a leaf object relates to a domain in one of three ways, reported with its source by member listing and by domains of an object: + + | Relation | Meaning | Source | + | :---- | :---- | :---- | + | Direct | the object itself carries the domain's backing tag | none | + | Inherited | the object inherits the backing tag from its catalog or schema | the nearest catalog or schema carrying it | + | Via subdomain | a visible subdomain of the domain has the object in its scope | that subdomain | + + Every relation that applies is reported, as a list of (relation, source) pairs, with no precedence: a table whose schema carries `finance` and which itself carries `trading` gets `[Inherited from cat.db, Via subdomain Trading]` under Finance, and an object in two subdomains gets one Via subdomain pair for each. Clients decide how to display them. Relations are always relative to the listed domain. An object is a member of the domain itself when its relations include Direct or Inherited. The relation is an enum; the exact field shape is an implementation choice. +- **Domains of an object**: for a leaf object, every domain the caller can see whose scope contains the object, each with its relations. An object tagged only `trading` gets Trading (Direct) and Finance (Via subdomain Trading). Catalogs and schemas do not support this lookup, since they are never members; the domain a catalog or schema passes down is visible from its tags. +- **Removing a member**: membership ends when the object no longer carries the backing tag, by the tag rules. To take an object out of a domain backed by a tag without a value, remove the tag by name (v1 association request), which removes all its values on the object; a v2 request removes one (tag, value) pair. An inherited membership can only be removed on the catalog or schema. +- **Domain filters**: Phase 1 adds no domain filter to other list APIs. When one is added, it matches scope, consistent with member listing. + +### Member listing + +`GET /domains/{domain}/objects` lists leaf objects, expanding catalogs and schemas: + +1. Resolve the backing tags of the domains in the selected scopes (by default the listed domain's whole scope: itself and its visible subdomains). +2. List the objects carrying them (`listMetadataObjectsForTag`, by tag and value). Leaf objects are candidates as they are; a tagged catalog or schema is replaced by the leaf objects under it, from Gravitino's store. +3. Keep the candidates whose effective tags match a selected backing tag (the matching rules above, nearest level wins), so a leaf object that overrides an inherited value drops out. Only bindings with a value need this check. +4. Drop objects the caller cannot access (`filterMetadataObject`), de-duplicate, and record each member's relations and sources. + +- **A view of recorded objects**: membership is defined by the rule above; the listing shows the objects recorded in Gravitino's store. Objects in an external catalog that Gravitino has not recorded yet are not listed, so an empty listing does not mean an object is not a member. +- **Cost**: a domain backed by a tag on a large catalog lists every recorded leaf object under it (see Open Questions). +- **Per caller**: the listing depends on the subdomains and objects the caller can see, so its length is not a property of the domain. Paging and counts come in Phase 2; counts follow the listing and depend on the caller too, so they never reveal assets the caller cannot see. + +### Access control + +**Domain privileges** + +| Privilege | On the metalake | On a domain | +| :---- | :---- | :---- | +| `MANAGE_DOMAIN` | Create domains and manage all of them | Edit, publish, subdomains, backing tag, delete; see it while `DRAFT` | +| `USE_DOMAIN` | See all published domains and browse their members | See this domain and browse its members | + +`MANAGE_DOMAIN` includes `USE_DOMAIN`; owners and the metalake owner hold both. There is no separate `CREATE_DOMAIN`. Privileges on a domain, and its ownership, also apply to its subdomains; metalake-level grants cover every domain. + +- **Access follows the current parent**: moving or detaching a subdomain changes who can see and manage it, without any grant changing. After Trading moves from Finance to Markets, Finance's holders lose access to it and Markets' holders gain it. A Finance manager can detach Trading through that inherited privilege, and then has none on it. Move and detach events carry the old and new parent, so an auditor can reconstruct why someone had access. + +**What each operation needs** + +| Operation | Required | +| :---- | :---- | +| See a domain | Published: `USE_DOMAIN`. Draft: owner or `MANAGE_DOMAIN` | +| Browse members, domains of an object | See the domain; results filtered by each object's own privileges; no tag privilege | +| Add or remove members | Existing tag authorization (`CAN_ACCESS_METADATA_AND_TAG`): access to the object and `APPLY_TAG` on the backing tag. No domain privilege | +| Create with a new tag | `MANAGE_DOMAIN` and `CREATE_TAG` on the metalake | +| Create with an existing tag | `MANAGE_DOMAIN` on the metalake and ownership of the tag (or the metalake) | +| Create a subdomain | Also `MANAGE_DOMAIN` on the parent | +| Set or replace the backing tag | `MANAGE_DOMAIN` and ownership of the new tag (or the metalake) | +| Remove the backing tag | `MANAGE_DOMAIN` | +| Attach / move | `MANAGE_DOMAIN` on both the domain and the new parent | +| Detach | `MANAGE_DOMAIN` on the domain (holding it on the current parent is enough) | +| Publish, unpublish, edit, delete | `MANAGE_DOMAIN` | + +- **Membership is controlled by tag privileges only**: anyone with read access to an asset and `APPLY_TAG` on a backing tag can add the asset, without the asset owner's consent. This is the existing tag behavior; a steward controls membership by controlling `APPLY_TAG` on the backing tag. +- Browsing without `VIEW_TAG` is safe because only a tag's owner can bind it to a domain: exposing that one (tag, value) to the domain's viewers is the owner's decision, and viewers still see only objects they can access. +- A user with `MANAGE_DOMAIN` but no `APPLY_TAG` on the tag can manage the domain but not add members. + +**Implementation** + +- Every domain endpoint has a single `{domain}` path parameter. Domain names carry no parent, so the generic authorization expressions cannot derive it; the parent part of the check is done in the domain code: a privilege on the domain, or else on its current parent read from `DomainRel` (one lookup, batched when filtering a list of domains). The generic authorization framework is unchanged. Review Comment: [P1] Preserve DENY precedence when inheriting privileges Please distinguish a missing local grant from an explicit denial before falling back to the parent. For example, a non-owner with `ALLOW USE_DOMAIN` on Finance and `DENY USE_DOMAIN` on Trading would pass the parent check after the local check fails. This conflicts with the existing [deny-overrides-allow rule](https://github.com/apache/gravitino/blob/7365fe5c548cfb3f321bbdfccf3a2e38ee4639bf/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionConverter.java#L223-L246). Please define how denies are combined across the metalake, current parent and domain, including any ownership exceptions, so the imperative check preserves that rule. ########## design-docs/domain-design.md: ########## @@ -0,0 +1,325 @@ +<!-- + 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: Domain + +| Version | Author | Date | Status | +| :---- | :---- | :---- | :---- | +| 1.24 | Jerry Shao | 2026-10-16 | Draft | + +## Goals + +1. **Business grouping**: group assets from any catalog under named domains (e.g. "Finance") for discovery, browsing and agent retrieval. An asset can belong to several domains. +2. **Membership is tags**: a leaf asset is a member when it carries the domain's backing tag, directly or inherited from its catalog or schema. There is no separate membership store. +3. **Separate privileges**: domain privileges govern the `Domain` object; tag privileges govern membership. Membership grants no access to data. +4. **One level of subdomains**, each with one parent; a subdomain can be moved. +5. **Draft and published status**: a `DRAFT` domain is returned only to its owner and managers, the same on REST, MCP and any UI. +6. **Inherited membership**: tagging a catalog or schema makes the leaf objects under it members, through Gravitino's existing tag inheritance. + +## Non-Goals + +1. **Approval workflow**: adding members, attaching subdomains and publishing take effect at once; status is a manual switch. +2. **Data-access grants on domains**: a domain carries no data privileges such as `SELECT_TABLE` (see Grants and policies). +3. **Domain-level policies**: domains add no policy mechanism; policies keep following tags (see Grants and policies). +4. **Deeper or multi-parent hierarchies**: the storage allows them later without a data migration. +5. **Automatic membership**, glossary and metric designs, and the ABAC policy language. Glossary terms can become members once they are taggable and added to the member types. +6. **UI**: this design covers the server, REST APIs, clients and MCP tools. + +## Usage Scenario + +1. A steward with `MANAGE_DOMAIN` and `CREATE_TAG` on the metalake creates domain Finance with a new backing tag `finance`. The steward owns both. +2. The steward creates subdomain Trading under Finance with a new backing tag `trading`, and grants `APPLY_TAG` on tag `trading` to the trading table owners. +3. A table owner tags `cat.db.trades` with `trading`. The table is a member of Trading, and is listed under Finance as "via subdomain Trading". Tagging schema `cat.db` with `finance` would make every leaf object in it a member of Finance. +4. A user or agent with `USE_DOMAIN` on Finance (which also covers Trading) or on Trading browses it and gets the tables they can access. +5. Trading is moved under Markets. Its members are now listed under Markets; nothing is retagged. + +## Design + +### Data model + +``` +Domain { id, name, comment, status: DRAFT | PUBLISHED, properties, + backingTagId?, backingTagValue?, audit_info } +DomainRel { parentId, childId, audit_info } // one edge per subdomain +``` + +- Domains live under a metalake. A subdomain and its parent are in the same metalake. +- Domain names are unique within a metalake across both levels: top-level domains and subdomains share one namespace, so a domain is addressed by its name alone. Domain names follow the same constraints as tag names (up to 128 characters) and may contain `/` (see REST API, Encoding). +- A unique index on `DomainRel (child_id, deleted_at)` keeps one parent per domain. `parent` is not stored on `Domain`; the API derives it. Dropping the index and checks would allow multiple parents later without a data migration. +- The backing tag is stored on the domain row as the tag's ID and an optional value, with an index on `(metalake_id, backing_tag_id, backing_tag_value, deleted_at)` for the reverse lookup "which domain does this (tag, value) back". `DOMAIN` is not a taggable object type, so the tag code paths are unchanged (see Alternatives considered). +- The binding refers to the tag by ID, and matching compares tag IDs: if the tag is deleted, the ID stays on the row and matches nothing, and a new tag with the same name does not match it. +- Domains, their parent and their backing tag are not cached; every request reads them from the store, so all servers see a move or a backing-tag change at once. Caching them later needs a version bump of the affected domain rows and a changelog entry. + +### Backing tag + +Each domain has **at most one** backing tag: an existing tag, optionally with a value (tag values: apache/gravitino#12162). Creating a domain always sets one; a domain has none only after its backing tag is removed or deleted, until a new one is set. + +- **Explicit at creation**: every create request names the backing tag; there is no default name derived from the domain. The caller either + - creates a new tag: a tag without values (`NO_VALUE`) with the given name, owned by the caller, which needs `CREATE_TAG`. If a tag with that name exists, creation fails with 409 `TagAlreadyExistsException`; an existing tag is never adopted implicitly; or + - binds an existing (tag, value), such as `dept=finance`. The caller must own the tag or the metalake; a missing tag fails with 404. + + A create request without a backing tag, or one that creates a new tag and also gives a value, fails with 400. +- **Tag value rules apply as for any assignment**: a tag with a non-empty allowed-values list can only be bound with one of its values; a `NO_VALUE` tag only without a value. +- **No sharing**: following the tag assignment rules, a tag has either one binding without a value, or bindings with values where each value backs at most one domain; the two are never mixed. A conflicting binding fails with 409 `BackingTagAlreadyBoundException` (a new `AlreadyExistsException` subclass). The reverse lookup is therefore one domain per (tag, value). +- **Matching** follows the tag lookup semantics (`GET /tags/{tag}/objects`). A backing tag without a value matches every assignment of the tag, with or without values, like a lookup without `value`. A backing tag with a value matches assignments whose values contain it, exact and case-sensitive, like `?value=`; a valueless assignment never matches it. The same rule applies to member listing and domains of an object, always against the object's effective assignment. +- **Changing it**: `PUT /domains/{domain}/backingTag` replaces or removes it on the server, under the domain and tag row locks. A missing tag fails with 404, a value the tag does not allow with 400, a tag the caller does not own (nor the metalake) with 403, and a binding that conflicts with another domain with 409; setting the current tag is a no-op. Assets carrying only the old tag drop out; nothing is migrated. This endpoint is the only way to change a backing tag: `DOMAIN` is not taggable, so the generic tag association API rejects it. +- **Never renamed**: moving or renaming a domain does not rename its tag; the hierarchy lives in `DomainRel`. A renamed tag keeps backing the domain, since the binding is by tag ID. +- **Deleting the tag** behaves as for any tag: it is allowed, and the tag's assignments on assets are removed with it. Its domain is left with no backing tag (`backingTag` reads as empty) and no members until a new one is set; the membership cannot be recovered by recreating a tag with the same name. Only the tag deletion event records this. +- **Separate from domain privileges**: the domain owner and the tag owner are independent. Managing a domain grants no `APPLY_TAG` on its tag, and transferring the domain's ownership does not transfer the tag's. + +### Subdomains + +Every operation that sets a parent (create a subdomain, attach, move) checks: + +| # | Constraint | +| :---- | :---- | +| C1 | A domain cannot be its own parent | +| C2 | The parent is top-level | +| C3 | The attached domain has no subdomains | +| C4 | Parent and child are in the same metalake | +| C5 | At most one parent; attaching a domain that has one is a move | +| C6 | A `PUBLISHED` domain can only be attached under a `PUBLISHED` parent | + +C1–C3 keep the hierarchy one level deep and acyclic, so no cycle detection is needed. + +- **Attach / move** re-points the child's single edge in one transaction. Attaching to the current parent is a no-op. +- **Detach** removes the current edge; the child becomes top-level. +- **Delete** is refused while the domain has subdomains. Deleting removes the domain row and its own parent edge. The tag and the tags on assets stay. +- **Membership and scope**: membership comes from backing tags only (see Membership and inheritance), so an asset tagged only `trading` is a member of Trading, not of Finance. A top-level domain's *scope* is its own members plus the members of the subdomains the caller can see; a subdomain's scope is its own members. Member listing and domains of an object report scope, with the relation of each entry; policies follow tags only (see Grants and policies). + +### Locking + +Domain operations use the store's existing locking primitives, in one global order: the metalake fence (`LiveEndpointService.lockLiveEndpoint`, shared), then the domain rows involved in ID order, then tag rows (exclusive, as `lockTagsForAssignment` does). Each row is locked once, in its final mode, so no lock is upgraded inside a transaction. `DOMAIN` is added to `LiveEndpointSQLProvider`. + +| Operation | Rows locked | Conflicts it excludes | +| :---- | :---- | :---- | +| Create a top-level domain | metalake (shared) → existing tag (exclusive) | metalake drop; two domains binding the same (tag, value) | +| Create a subdomain | metalake (shared) → parent (shared) → existing tag (exclusive) | the parent being attached elsewhere (C2) | +| Attach / move | child (exclusive) and new parent (shared), in ID order | the new parent being attached elsewhere (C2); a subdomain being created under the child (C3); crossing moves | +| Detach | child (exclusive) | | +| Publish | domain (exclusive) and parent (shared) | the parent being unpublished | Review Comment: [P2] Revalidate the parent edge after acquiring locks The parent is discovered before taking domain locks in ID order, so it can change before the child lock is acquired. For example, publish resolves Trading under Finance; a concurrent move to Markets commits first; publish then holds locks on Trading and Finance. The name recheck passes, but Markets is unprotected and can be unpublished after the status check, leaving a published child under a draft parent. Please reread `DomainRel` after locking and retry with the complete lock set if the parent changed; evaluate C6 and inherited authorization against that protected relation. ########## design-docs/domain-design.md: ########## @@ -0,0 +1,325 @@ +<!-- + 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: Domain + +| Version | Author | Date | Status | +| :---- | :---- | :---- | :---- | +| 1.24 | Jerry Shao | 2026-10-16 | Draft | + +## Goals + +1. **Business grouping**: group assets from any catalog under named domains (e.g. "Finance") for discovery, browsing and agent retrieval. An asset can belong to several domains. +2. **Membership is tags**: a leaf asset is a member when it carries the domain's backing tag, directly or inherited from its catalog or schema. There is no separate membership store. +3. **Separate privileges**: domain privileges govern the `Domain` object; tag privileges govern membership. Membership grants no access to data. +4. **One level of subdomains**, each with one parent; a subdomain can be moved. +5. **Draft and published status**: a `DRAFT` domain is returned only to its owner and managers, the same on REST, MCP and any UI. +6. **Inherited membership**: tagging a catalog or schema makes the leaf objects under it members, through Gravitino's existing tag inheritance. + +## Non-Goals + +1. **Approval workflow**: adding members, attaching subdomains and publishing take effect at once; status is a manual switch. +2. **Data-access grants on domains**: a domain carries no data privileges such as `SELECT_TABLE` (see Grants and policies). +3. **Domain-level policies**: domains add no policy mechanism; policies keep following tags (see Grants and policies). +4. **Deeper or multi-parent hierarchies**: the storage allows them later without a data migration. +5. **Automatic membership**, glossary and metric designs, and the ABAC policy language. Glossary terms can become members once they are taggable and added to the member types. +6. **UI**: this design covers the server, REST APIs, clients and MCP tools. + +## Usage Scenario + +1. A steward with `MANAGE_DOMAIN` and `CREATE_TAG` on the metalake creates domain Finance with a new backing tag `finance`. The steward owns both. +2. The steward creates subdomain Trading under Finance with a new backing tag `trading`, and grants `APPLY_TAG` on tag `trading` to the trading table owners. +3. A table owner tags `cat.db.trades` with `trading`. The table is a member of Trading, and is listed under Finance as "via subdomain Trading". Tagging schema `cat.db` with `finance` would make every leaf object in it a member of Finance. +4. A user or agent with `USE_DOMAIN` on Finance (which also covers Trading) or on Trading browses it and gets the tables they can access. +5. Trading is moved under Markets. Its members are now listed under Markets; nothing is retagged. + +## Design + +### Data model + +``` +Domain { id, name, comment, status: DRAFT | PUBLISHED, properties, + backingTagId?, backingTagValue?, audit_info } +DomainRel { parentId, childId, audit_info } // one edge per subdomain +``` + +- Domains live under a metalake. A subdomain and its parent are in the same metalake. +- Domain names are unique within a metalake across both levels: top-level domains and subdomains share one namespace, so a domain is addressed by its name alone. Domain names follow the same constraints as tag names (up to 128 characters) and may contain `/` (see REST API, Encoding). +- A unique index on `DomainRel (child_id, deleted_at)` keeps one parent per domain. `parent` is not stored on `Domain`; the API derives it. Dropping the index and checks would allow multiple parents later without a data migration. +- The backing tag is stored on the domain row as the tag's ID and an optional value, with an index on `(metalake_id, backing_tag_id, backing_tag_value, deleted_at)` for the reverse lookup "which domain does this (tag, value) back". `DOMAIN` is not a taggable object type, so the tag code paths are unchanged (see Alternatives considered). +- The binding refers to the tag by ID, and matching compares tag IDs: if the tag is deleted, the ID stays on the row and matches nothing, and a new tag with the same name does not match it. +- Domains, their parent and their backing tag are not cached; every request reads them from the store, so all servers see a move or a backing-tag change at once. Caching them later needs a version bump of the affected domain rows and a changelog entry. + +### Backing tag + +Each domain has **at most one** backing tag: an existing tag, optionally with a value (tag values: apache/gravitino#12162). Creating a domain always sets one; a domain has none only after its backing tag is removed or deleted, until a new one is set. + +- **Explicit at creation**: every create request names the backing tag; there is no default name derived from the domain. The caller either + - creates a new tag: a tag without values (`NO_VALUE`) with the given name, owned by the caller, which needs `CREATE_TAG`. If a tag with that name exists, creation fails with 409 `TagAlreadyExistsException`; an existing tag is never adopted implicitly; or + - binds an existing (tag, value), such as `dept=finance`. The caller must own the tag or the metalake; a missing tag fails with 404. + + A create request without a backing tag, or one that creates a new tag and also gives a value, fails with 400. +- **Tag value rules apply as for any assignment**: a tag with a non-empty allowed-values list can only be bound with one of its values; a `NO_VALUE` tag only without a value. +- **No sharing**: following the tag assignment rules, a tag has either one binding without a value, or bindings with values where each value backs at most one domain; the two are never mixed. A conflicting binding fails with 409 `BackingTagAlreadyBoundException` (a new `AlreadyExistsException` subclass). The reverse lookup is therefore one domain per (tag, value). +- **Matching** follows the tag lookup semantics (`GET /tags/{tag}/objects`). A backing tag without a value matches every assignment of the tag, with or without values, like a lookup without `value`. A backing tag with a value matches assignments whose values contain it, exact and case-sensitive, like `?value=`; a valueless assignment never matches it. The same rule applies to member listing and domains of an object, always against the object's effective assignment. +- **Changing it**: `PUT /domains/{domain}/backingTag` replaces or removes it on the server, under the domain and tag row locks. A missing tag fails with 404, a value the tag does not allow with 400, a tag the caller does not own (nor the metalake) with 403, and a binding that conflicts with another domain with 409; setting the current tag is a no-op. Assets carrying only the old tag drop out; nothing is migrated. This endpoint is the only way to change a backing tag: `DOMAIN` is not taggable, so the generic tag association API rejects it. +- **Never renamed**: moving or renaming a domain does not rename its tag; the hierarchy lives in `DomainRel`. A renamed tag keeps backing the domain, since the binding is by tag ID. +- **Deleting the tag** behaves as for any tag: it is allowed, and the tag's assignments on assets are removed with it. Its domain is left with no backing tag (`backingTag` reads as empty) and no members until a new one is set; the membership cannot be recovered by recreating a tag with the same name. Only the tag deletion event records this. +- **Separate from domain privileges**: the domain owner and the tag owner are independent. Managing a domain grants no `APPLY_TAG` on its tag, and transferring the domain's ownership does not transfer the tag's. + +### Subdomains + +Every operation that sets a parent (create a subdomain, attach, move) checks: + +| # | Constraint | +| :---- | :---- | +| C1 | A domain cannot be its own parent | +| C2 | The parent is top-level | +| C3 | The attached domain has no subdomains | +| C4 | Parent and child are in the same metalake | +| C5 | At most one parent; attaching a domain that has one is a move | +| C6 | A `PUBLISHED` domain can only be attached under a `PUBLISHED` parent | + +C1–C3 keep the hierarchy one level deep and acyclic, so no cycle detection is needed. + +- **Attach / move** re-points the child's single edge in one transaction. Attaching to the current parent is a no-op. +- **Detach** removes the current edge; the child becomes top-level. +- **Delete** is refused while the domain has subdomains. Deleting removes the domain row and its own parent edge. The tag and the tags on assets stay. +- **Membership and scope**: membership comes from backing tags only (see Membership and inheritance), so an asset tagged only `trading` is a member of Trading, not of Finance. A top-level domain's *scope* is its own members plus the members of the subdomains the caller can see; a subdomain's scope is its own members. Member listing and domains of an object report scope, with the relation of each entry; policies follow tags only (see Grants and policies). + +### Locking + +Domain operations use the store's existing locking primitives, in one global order: the metalake fence (`LiveEndpointService.lockLiveEndpoint`, shared), then the domain rows involved in ID order, then tag rows (exclusive, as `lockTagsForAssignment` does). Each row is locked once, in its final mode, so no lock is upgraded inside a transaction. `DOMAIN` is added to `LiveEndpointSQLProvider`. + +| Operation | Rows locked | Conflicts it excludes | +| :---- | :---- | :---- | +| Create a top-level domain | metalake (shared) → existing tag (exclusive) | metalake drop; two domains binding the same (tag, value) | +| Create a subdomain | metalake (shared) → parent (shared) → existing tag (exclusive) | the parent being attached elsewhere (C2) | +| Attach / move | child (exclusive) and new parent (shared), in ID order | the new parent being attached elsewhere (C2); a subdomain being created under the child (C3); crossing moves | +| Detach | child (exclusive) | | +| Publish | domain (exclusive) and parent (shared) | the parent being unpublished | +| Unpublish, delete | domain (exclusive) | a subdomain being created, attached or published under it | +| Set or remove the backing tag | domain (exclusive) → tag (exclusive) | a conflicting binding; the tag being deleted | +| Rename, alter | domain (exclusive) | | + +- Every name-addressed operation resolves the name to an ID, locks the row, then re-checks that the row still has that name and is not deleted; otherwise it fails with 404. +- A tag named in a request is re-checked after locking; a missing tag fails with 404 instead of being skipped. +- A new backing tag has no row to lock yet; its name is protected by the tag name's unique key, so a concurrent create with the same name fails with 409. +- The unique index on `DomainRel` and the domain row's own version (optimistic locking, never exposed to callers) remain as backstops. + +### Status + +- New domains are `DRAFT`. The owner or a `MANAGE_DOMAIN` holder publishes or unpublishes, at any time and without approval. +- **Visibility is the same on every surface** (REST, MCP, UI). A `DRAFT` domain is returned only to its owner and to `MANAGE_DOMAIN` holders on it, on its parent or on the metalake. Domain APIs do not return it to anyone else: lists omit it, get returns 404, and it is not in `list_domains`, member listings, an object's domains or a parent's roll-up. Responses carry `status`, so a caller that sees drafts can filter them out. +- **Known channels**: hiding a draft is not confidentiality. Its existence can still show through: + - creating a domain with the same name fails with 409; + - its backing tag is an ordinary tag, visible to tag readers and on tagged assets (use a non-descriptive tag name if that matters); + - `parent` is always returned, even when the caller cannot see the parent. Only the name is exposed; getting the parent still returns 404, and nothing can be done on it. +- **Consistency**: publishing a subdomain needs a published parent; unpublishing needs no published subdomains (C6 covers attach and move). +- Assets can be tagged into a `DRAFT` domain to prepare it; anyone with `APPLY_TAG` on its tag can do so. +- Roll-ups depend on the caller: someone who can see a `DRAFT` subdomain sees its members under the parent; others do not. + +### Membership and inheritance + +- **Rule**: an object's effective tags are its own plus its schema's and catalog's; for the same tag the nearest level wins, and different tags accumulate. A leaf object (see Member types) is a member of domain D when its effective tags match D's backing (tag, value). Membership reuses core's `EffectiveTagResolver`, which applies no tag-privilege filtering: domain visibility and object access are applied afterwards, so a caller needs no tag privilege. +- **Different tags accumulate**: with schema `finance` and table `sales`, the table is in both Finance and Sales, and a child cannot leave a domain it inherits. +- **Values of one tag** backing different domains override each other: with schema `dept=finance` and table `dept=hr`, the table is only in the `dept=hr` domain, unless it uses `dept=[finance, hr]`. This is the one case where a child can leave an inherited domain, and only for bindings with a value; a binding without a value matches every value. +- **Member types**: leaf objects only: tables, views, filesets, topics, models and semantic models (`DomainMember.SUPPORTED_TYPES`). Catalogs and schemas are never members: one that carries a backing tag passes membership to the leaf objects under it through tag inheritance. Columns and functions are not members either: a column or function carrying a backing tag is not listed. A type that becomes taggable later joins only when it is added to this list. Metrics belong through their semantic model. +- **Relations**: a leaf object relates to a domain in one of three ways, reported with its source by member listing and by domains of an object: + + | Relation | Meaning | Source | + | :---- | :---- | :---- | + | Direct | the object itself carries the domain's backing tag | none | + | Inherited | the object inherits the backing tag from its catalog or schema | the nearest catalog or schema carrying it | + | Via subdomain | a visible subdomain of the domain has the object in its scope | that subdomain | + + Every relation that applies is reported, as a list of (relation, source) pairs, with no precedence: a table whose schema carries `finance` and which itself carries `trading` gets `[Inherited from cat.db, Via subdomain Trading]` under Finance, and an object in two subdomains gets one Via subdomain pair for each. Clients decide how to display them. Relations are always relative to the listed domain. An object is a member of the domain itself when its relations include Direct or Inherited. The relation is an enum; the exact field shape is an implementation choice. +- **Domains of an object**: for a leaf object, every domain the caller can see whose scope contains the object, each with its relations. An object tagged only `trading` gets Trading (Direct) and Finance (Via subdomain Trading). Catalogs and schemas do not support this lookup, since they are never members; the domain a catalog or schema passes down is visible from its tags. +- **Removing a member**: membership ends when the object no longer carries the backing tag, by the tag rules. To take an object out of a domain backed by a tag without a value, remove the tag by name (v1 association request), which removes all its values on the object; a v2 request removes one (tag, value) pair. An inherited membership can only be removed on the catalog or schema. +- **Domain filters**: Phase 1 adds no domain filter to other list APIs. When one is added, it matches scope, consistent with member listing. + +### Member listing + +`GET /domains/{domain}/objects` lists leaf objects, expanding catalogs and schemas: + +1. Resolve the backing tags of the domains in the selected scopes (by default the listed domain's whole scope: itself and its visible subdomains). +2. List the objects carrying them (`listMetadataObjectsForTag`, by tag and value). Leaf objects are candidates as they are; a tagged catalog or schema is replaced by the leaf objects under it, from Gravitino's store. +3. Keep the candidates whose effective tags match a selected backing tag (the matching rules above, nearest level wins), so a leaf object that overrides an inherited value drops out. Only bindings with a value need this check. +4. Drop objects the caller cannot access (`filterMetadataObject`), de-duplicate, and record each member's relations and sources. + +- **A view of recorded objects**: membership is defined by the rule above; the listing shows the objects recorded in Gravitino's store. Objects in an external catalog that Gravitino has not recorded yet are not listed, so an empty listing does not mean an object is not a member. +- **Cost**: a domain backed by a tag on a large catalog lists every recorded leaf object under it (see Open Questions). +- **Per caller**: the listing depends on the subdomains and objects the caller can see, so its length is not a property of the domain. Paging and counts come in Phase 2; counts follow the listing and depend on the caller too, so they never reveal assets the caller cannot see. + +### Access control + +**Domain privileges** + +| Privilege | On the metalake | On a domain | +| :---- | :---- | :---- | +| `MANAGE_DOMAIN` | Create domains and manage all of them | Edit, publish, subdomains, backing tag, delete; see it while `DRAFT` | +| `USE_DOMAIN` | See all published domains and browse their members | See this domain and browse its members | + +`MANAGE_DOMAIN` includes `USE_DOMAIN`; owners and the metalake owner hold both. There is no separate `CREATE_DOMAIN`. Privileges on a domain, and its ownership, also apply to its subdomains; metalake-level grants cover every domain. + +- **Access follows the current parent**: moving or detaching a subdomain changes who can see and manage it, without any grant changing. After Trading moves from Finance to Markets, Finance's holders lose access to it and Markets' holders gain it. A Finance manager can detach Trading through that inherited privilege, and then has none on it. Move and detach events carry the old and new parent, so an auditor can reconstruct why someone had access. + +**What each operation needs** + +| Operation | Required | +| :---- | :---- | +| See a domain | Published: `USE_DOMAIN`. Draft: owner or `MANAGE_DOMAIN` | +| Browse members, domains of an object | See the domain; results filtered by each object's own privileges; no tag privilege | +| Add or remove members | Existing tag authorization (`CAN_ACCESS_METADATA_AND_TAG`): access to the object and `APPLY_TAG` on the backing tag. No domain privilege | +| Create with a new tag | `MANAGE_DOMAIN` and `CREATE_TAG` on the metalake | +| Create with an existing tag | `MANAGE_DOMAIN` on the metalake and ownership of the tag (or the metalake) | +| Create a subdomain | Also `MANAGE_DOMAIN` on the parent | +| Set or replace the backing tag | `MANAGE_DOMAIN` and ownership of the new tag (or the metalake) | +| Remove the backing tag | `MANAGE_DOMAIN` | +| Attach / move | `MANAGE_DOMAIN` on both the domain and the new parent | +| Detach | `MANAGE_DOMAIN` on the domain (holding it on the current parent is enough) | +| Publish, unpublish, edit, delete | `MANAGE_DOMAIN` | + +- **Membership is controlled by tag privileges only**: anyone with read access to an asset and `APPLY_TAG` on a backing tag can add the asset, without the asset owner's consent. This is the existing tag behavior; a steward controls membership by controlling `APPLY_TAG` on the backing tag. +- Browsing without `VIEW_TAG` is safe because only a tag's owner can bind it to a domain: exposing that one (tag, value) to the domain's viewers is the owner's decision, and viewers still see only objects they can access. +- A user with `MANAGE_DOMAIN` but no `APPLY_TAG` on the tag can manage the domain but not add members. + +**Implementation** + +- Every domain endpoint has a single `{domain}` path parameter. Domain names carry no parent, so the generic authorization expressions cannot derive it; the parent part of the check is done in the domain code: a privilege on the domain, or else on its current parent read from `DomainRel` (one lookup, batched when filtering a list of domains). The generic authorization framework is unchanged. +- A parent or backing tag named in a request body (`parent` on create and attach, `backingTag` on create and on `PUT .../backingTag`) is checked imperatively: `MANAGE_DOMAIN` on the new parent; for an existing tag, ownership of the tag (or the metalake); for a new tag, `CREATE_TAG` on the metalake. + +### Grants and policies + +- **No data-access grants on domains.** Since anyone with `APPLY_TAG` on a backing tag can add an asset, an allow grant on a domain would turn tagging into granting access without the asset owner's consent. Possible future designs are listed in Open Questions. +- **Policies follow tags only.** Domains add no policy mechanism, and there are no "domain policies". An asset is governed by the policies associated with the tags it carries, the backing tag included, under the existing tag-policy semantics (association selectors for all values or one value). A domain passes no policy to its members or to its subdomains: domains and assets have no containment relation. An asset listed under Finance only via subdomain Trading is not governed by the policies on Finance's backing tag. Conversely, tagging an asset with a backing tag brings it under that tag's policies, as for any tag, so `APPLY_TAG` on a backing tag has a governance effect as well. + +### REST API + +Under `/api/metalakes/{metalake}`, following Gravitino conventions: `?details=` on lists, `PUT` + updates to alter, `PATCH` + a set request to switch state, `DropResponse` on delete, owners through the owner API. + +| Operation | Endpoint | Request → response | +| :---- | :---- | :---- | +| List | `GET /domains?details=false` | → `NameListResponse` / `DomainListResponse` | +| Create | `POST /domains` | `DomainCreateRequest{name, comment, properties, backingTag{name, value, create}, parent}` → `DomainResponse` | +| Get | `GET /domains/{domain}` | → `DomainResponse` | +| Alter | `PUT /domains/{domain}` | `DomainUpdatesRequest{updates: [rename, updateComment, setProperty, removeProperty]}` → `DomainResponse` | +| Publish / unpublish | `PATCH /domains/{domain}` | `DomainSetRequest{status}` → `DomainResponse` | +| Drop | `DELETE /domains/{domain}` | → `DropResponse` (`false` if absent) | +| Attach / move | `PUT /domains/{domain}/parent` | `DomainParentSetRequest{parent}` → `DomainResponse` | +| Detach | `DELETE /domains/{domain}/parent` | → `DomainResponse` (no-op for a top-level domain) | +| Set / remove backing tag | `PUT /domains/{domain}/backingTag` | `DomainBackingTagSetRequest{backingTag{name, value}}` (null `backingTag` removes it) → `DomainResponse` | +| List subdomains | `GET /domains/{domain}/subdomains?details=false` | → `NameListResponse` / `DomainListResponse` | +| List members | `GET /domains/{domain}/objects` | → `DomainMemberListResponse` | +| Domains of an object | `GET /objects/{type}/{fullName}/domains?details=false` (leaf types only) | → `NameListResponse` / `DomainListResponse` | +| Add / remove members | Existing `POST /objects/{type}/{fullName}/tags` with the domain's backing tag | `TagValuesAssociateRequest` (v2) or `TagsAssociateRequest` (v1) | + +``` +DomainDTO { name, comment, properties, status, parent, backingTag{name, value}, audit, + relations? } // [{relation, source?}], only on domains of an object +DomainMemberDTO { object: MetadataObjectDTO, relations: [{relation, source?}] } +``` + +- **Create**: `backingTag.create = true` creates a new tag with the given name (no `value`); `false` binds the existing (tag, value). Domain, new tag and parent edge are written in one transaction; on any failure nothing is created, and no new tag is left behind. +- **Membership requests**: adding a member with a value needs the v2 `TagValuesAssociateRequest` (`Content-Type: application/vnd.gravitino.v2+json`), which carries (name, value) pairs. Removing a tag by name with the v1 `TagsAssociateRequest` removes all its values on the object. The Java and Python clients already support both. +- **Parent endpoints** address the child only, matching `Domain#attachToParent` and `Domain#detachFromParent`; detaching needs no parent name. +- **State changes return the domain**: publish, unpublish, attach, move, detach and backing-tag changes return the updated domain, because the Java API returns a new `Domain` snapshot from them. +- **Concurrency**: changes apply to the current state on the server and the last write wins, as for other Gravitino metadata operations; there is no version check. Detach removes the current parent edge and removing the backing tag removes the current one, whatever the caller's snapshot shows; detaching a top-level domain is a no-op, not an error. Domains are addressed by name, so if a domain is renamed and a new one takes its old name, later calls with that name act on the new domain. +- **Member listing parameters**: + - `domains` (repeatable, `?domains=Tax&domains=Trading`; not comma-separated, since domain names may contain `,`): each name, the listed domain itself or one of its visible subdomains, selects that domain's scope, and the result is the union. On Finance with subdomains Tax and Trading, not setting it or `domains=Finance` lists the whole scope (Finance, Tax and Trading), `domains=Tax` lists only Tax's members, and `domains=Tax&domains=Trading` lists Tax's and Trading's. Names that are neither the domain nor a subdomain visible to the caller are ignored; if all given names are ignored, the result is empty rather than everything, so a typo cannot widen it. + - `types` (comma-separated, each a leaf member type) and `nameContains` (case-insensitive, on the full name). Filters apply to the expanded leaf list, so `types=TABLE` finds the tables under a tagged catalog. + - The listed domain's own members are those whose relations include Direct or Inherited. + - No paging in Phase 1, like the other core list APIs; paging and counts come in Phase 2. +- **Errors**: + - 404: `NoSuchDomainException` (missing, or a `DRAFT` domain the caller cannot see); `NoSuchTagException` (a backing tag to bind that does not exist). + - 409: `DomainAlreadyExistsException` (create, or rename to an existing name); `NonEmptyDomainException` (drop with subdomains); `TagAlreadyExistsException` (a new backing tag whose name exists); `BackingTagAlreadyBoundException` (a binding that conflicts with another domain). + - 400: `IllegalArgumentException` (C1–C6, status consistency, a value the tag does not allow, a create request without a backing tag or with both `create` and a value, a `DOMAIN` target on the generic tag association API). + - 403: `ForbiddenException` (binding a tag the caller does not own, nor the metalake; creating a new tag without `CREATE_TAG`). +- **Encoding**: tag and domain names may contain `/`, which must be URL-encoded (`%2F`) in every path segment: `{tag}`, and `{domain}` in `/domains/{domain}`, `/objects/domain/{domain}/...`, `/owners/domain/{domain}` and `/permissions/roles/{role}/domain/{domain}/grant|revoke`. The Java, Python and MCP clients already encode path segments, and Gravitino's Jetty 9.4 / Jersey stack decodes them into the path parameters. An end-to-end test through the real server, with authentication and authorization enabled, will cover these paths, including filters that read the decoded path. Deployments behind a reverse proxy must pass `%2F` through without decoding it (for example, nginx `proxy_pass` without a URI part), and a later Jetty upgrade must keep ambiguous path separators allowed. +- **Reused**: owners at `/owners/domain/{domain}`; privileges through the role and permission APIs, with `DOMAIN` as a securable type. +- **Clients** (Java, Python) mirror the tag clients: + - `DomainOperations` on `GravitinoMetalake`: list, get, `createDomain` (top-level domains only, with an explicit backing tag), alter and delete. + - On a `Domain`, each returning a new `Domain` snapshot: `publish()`, `unpublish()`, `attachToParent(parent)` (attach or move), `detachFromParent()`, and `updateBackingTag(TagValue)` (`null` removes it; maps to `PUT /domains/{domain}/backingTag`). Also on a `Domain`: `createSubdomain(...)` (with an explicit backing tag), `listSubdomains()` / `listSubdomainsInfo()` and `listMembers(DomainMemberFilter)`. + - `SupportsDomains` (`listDomains()`, `listDomainsInfo()`) on tables, views, filesets, topics, models and semantic models; not on catalogs, schemas, columns or functions. +- **Not included**: a batch lookup of the domains of many objects. + +### MCP tools + +A new read-only `domain` tool category in `mcp-server/`, added to `SUPPORTED_TOOL_TAGS` (enabled with `--include-tool-tags`): + +| Tool | Endpoint | +| :---- | :---- | +| `list_domains` | `GET /domains?details=true`; returns name, comment, parent, status and backing tag, so an agent can pick a domain | +| `get_domain_by_name` | `GET /domains/{domain}` | +| `list_metadata_by_domain` | `GET /domains/{domain}/objects`, with `domains` (a list of scopes to include, the domain itself or its subdomains; default the whole scope), `types`, `name_contains`; lets an agent look within a domain by type and name | +| `list_domains_for_metadata` | `GET /objects/{type}/{fullName}/domains`, for leaf objects | + +Adding members uses the existing `associate_tag_with_metadata` tool. It keeps removing tags by name (v1, all values) and is extended to accept (name, value) pairs and send the v2 request, so value-backed domains work. The MCP server forwards the caller's `Authorization` header, so the server's visibility rules apply; with OAuth client-credentials, the agent sees what that service identity sees. + +### Events and audit + +- Every domain operation emits pre, success and failure events through a `DomainEventDispatcher`, and reaches the audit log through the existing pipeline. Backing-tag changes are domain events. `Domain` and `DomainRel` carry `audit_info`. +- Membership changes are tag association changes, audited by the existing `AssociateTagsForMetadataObjectEvent`. To audit a domain's membership, query the events of its backing tag (and earlier ones if it was replaced). Tagging a catalog, or deleting a backing tag, is one tag-level event. Review Comment: [P2] Account for membership changes without tag mutations A table with no direct tag can inherit `finance` from schema A, then leave Finance when moved to untagged schema B. No tag association changes: this operation emits an [`AlterTableEvent`](https://github.com/apache/gravitino/blob/7365fe5c548cfb3f321bbdfccf3a2e38ee4639bf/core/src/main/java/org/apache/gravitino/listener/TableEventDispatcher.java#L175-L191). Creating or dropping a table under a tagged schema also changes membership without a tag event. Querying only backing-tag events therefore cannot reconstruct membership. Please include relevant asset lifecycle/hierarchy events in the audit contract, or explicitly limit this promise to auditing tag operations; per-member event fan-out is not required. ########## design-docs/domain-design.md: ########## @@ -0,0 +1,325 @@ +<!-- + 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: Domain + +| Version | Author | Date | Status | +| :---- | :---- | :---- | :---- | +| 1.24 | Jerry Shao | 2026-10-16 | Draft | + +## Goals + +1. **Business grouping**: group assets from any catalog under named domains (e.g. "Finance") for discovery, browsing and agent retrieval. An asset can belong to several domains. +2. **Membership is tags**: a leaf asset is a member when it carries the domain's backing tag, directly or inherited from its catalog or schema. There is no separate membership store. +3. **Separate privileges**: domain privileges govern the `Domain` object; tag privileges govern membership. Membership grants no access to data. +4. **One level of subdomains**, each with one parent; a subdomain can be moved. +5. **Draft and published status**: a `DRAFT` domain is returned only to its owner and managers, the same on REST, MCP and any UI. +6. **Inherited membership**: tagging a catalog or schema makes the leaf objects under it members, through Gravitino's existing tag inheritance. + +## Non-Goals + +1. **Approval workflow**: adding members, attaching subdomains and publishing take effect at once; status is a manual switch. +2. **Data-access grants on domains**: a domain carries no data privileges such as `SELECT_TABLE` (see Grants and policies). +3. **Domain-level policies**: domains add no policy mechanism; policies keep following tags (see Grants and policies). +4. **Deeper or multi-parent hierarchies**: the storage allows them later without a data migration. +5. **Automatic membership**, glossary and metric designs, and the ABAC policy language. Glossary terms can become members once they are taggable and added to the member types. +6. **UI**: this design covers the server, REST APIs, clients and MCP tools. + +## Usage Scenario + +1. A steward with `MANAGE_DOMAIN` and `CREATE_TAG` on the metalake creates domain Finance with a new backing tag `finance`. The steward owns both. +2. The steward creates subdomain Trading under Finance with a new backing tag `trading`, and grants `APPLY_TAG` on tag `trading` to the trading table owners. +3. A table owner tags `cat.db.trades` with `trading`. The table is a member of Trading, and is listed under Finance as "via subdomain Trading". Tagging schema `cat.db` with `finance` would make every leaf object in it a member of Finance. +4. A user or agent with `USE_DOMAIN` on Finance (which also covers Trading) or on Trading browses it and gets the tables they can access. +5. Trading is moved under Markets. Its members are now listed under Markets; nothing is retagged. + +## Design + +### Data model + +``` +Domain { id, name, comment, status: DRAFT | PUBLISHED, properties, + backingTagId?, backingTagValue?, audit_info } +DomainRel { parentId, childId, audit_info } // one edge per subdomain +``` + +- Domains live under a metalake. A subdomain and its parent are in the same metalake. +- Domain names are unique within a metalake across both levels: top-level domains and subdomains share one namespace, so a domain is addressed by its name alone. Domain names follow the same constraints as tag names (up to 128 characters) and may contain `/` (see REST API, Encoding). +- A unique index on `DomainRel (child_id, deleted_at)` keeps one parent per domain. `parent` is not stored on `Domain`; the API derives it. Dropping the index and checks would allow multiple parents later without a data migration. +- The backing tag is stored on the domain row as the tag's ID and an optional value, with an index on `(metalake_id, backing_tag_id, backing_tag_value, deleted_at)` for the reverse lookup "which domain does this (tag, value) back". `DOMAIN` is not a taggable object type, so the tag code paths are unchanged (see Alternatives considered). +- The binding refers to the tag by ID, and matching compares tag IDs: if the tag is deleted, the ID stays on the row and matches nothing, and a new tag with the same name does not match it. +- Domains, their parent and their backing tag are not cached; every request reads them from the store, so all servers see a move or a backing-tag change at once. Caching them later needs a version bump of the affected domain rows and a changelog entry. + +### Backing tag + +Each domain has **at most one** backing tag: an existing tag, optionally with a value (tag values: apache/gravitino#12162). Creating a domain always sets one; a domain has none only after its backing tag is removed or deleted, until a new one is set. + +- **Explicit at creation**: every create request names the backing tag; there is no default name derived from the domain. The caller either + - creates a new tag: a tag without values (`NO_VALUE`) with the given name, owned by the caller, which needs `CREATE_TAG`. If a tag with that name exists, creation fails with 409 `TagAlreadyExistsException`; an existing tag is never adopted implicitly; or + - binds an existing (tag, value), such as `dept=finance`. The caller must own the tag or the metalake; a missing tag fails with 404. + + A create request without a backing tag, or one that creates a new tag and also gives a value, fails with 400. +- **Tag value rules apply as for any assignment**: a tag with a non-empty allowed-values list can only be bound with one of its values; a `NO_VALUE` tag only without a value. +- **No sharing**: following the tag assignment rules, a tag has either one binding without a value, or bindings with values where each value backs at most one domain; the two are never mixed. A conflicting binding fails with 409 `BackingTagAlreadyBoundException` (a new `AlreadyExistsException` subclass). The reverse lookup is therefore one domain per (tag, value). +- **Matching** follows the tag lookup semantics (`GET /tags/{tag}/objects`). A backing tag without a value matches every assignment of the tag, with or without values, like a lookup without `value`. A backing tag with a value matches assignments whose values contain it, exact and case-sensitive, like `?value=`; a valueless assignment never matches it. The same rule applies to member listing and domains of an object, always against the object's effective assignment. +- **Changing it**: `PUT /domains/{domain}/backingTag` replaces or removes it on the server, under the domain and tag row locks. A missing tag fails with 404, a value the tag does not allow with 400, a tag the caller does not own (nor the metalake) with 403, and a binding that conflicts with another domain with 409; setting the current tag is a no-op. Assets carrying only the old tag drop out; nothing is migrated. This endpoint is the only way to change a backing tag: `DOMAIN` is not taggable, so the generic tag association API rejects it. +- **Never renamed**: moving or renaming a domain does not rename its tag; the hierarchy lives in `DomainRel`. A renamed tag keeps backing the domain, since the binding is by tag ID. +- **Deleting the tag** behaves as for any tag: it is allowed, and the tag's assignments on assets are removed with it. Its domain is left with no backing tag (`backingTag` reads as empty) and no members until a new one is set; the membership cannot be recovered by recreating a tag with the same name. Only the tag deletion event records this. +- **Separate from domain privileges**: the domain owner and the tag owner are independent. Managing a domain grants no `APPLY_TAG` on its tag, and transferring the domain's ownership does not transfer the tag's. + +### Subdomains + +Every operation that sets a parent (create a subdomain, attach, move) checks: + +| # | Constraint | +| :---- | :---- | +| C1 | A domain cannot be its own parent | +| C2 | The parent is top-level | +| C3 | The attached domain has no subdomains | +| C4 | Parent and child are in the same metalake | +| C5 | At most one parent; attaching a domain that has one is a move | +| C6 | A `PUBLISHED` domain can only be attached under a `PUBLISHED` parent | + +C1–C3 keep the hierarchy one level deep and acyclic, so no cycle detection is needed. + +- **Attach / move** re-points the child's single edge in one transaction. Attaching to the current parent is a no-op. +- **Detach** removes the current edge; the child becomes top-level. +- **Delete** is refused while the domain has subdomains. Deleting removes the domain row and its own parent edge. The tag and the tags on assets stay. +- **Membership and scope**: membership comes from backing tags only (see Membership and inheritance), so an asset tagged only `trading` is a member of Trading, not of Finance. A top-level domain's *scope* is its own members plus the members of the subdomains the caller can see; a subdomain's scope is its own members. Member listing and domains of an object report scope, with the relation of each entry; policies follow tags only (see Grants and policies). + +### Locking + +Domain operations use the store's existing locking primitives, in one global order: the metalake fence (`LiveEndpointService.lockLiveEndpoint`, shared), then the domain rows involved in ID order, then tag rows (exclusive, as `lockTagsForAssignment` does). Each row is locked once, in its final mode, so no lock is upgraded inside a transaction. `DOMAIN` is added to `LiveEndpointSQLProvider`. + +| Operation | Rows locked | Conflicts it excludes | +| :---- | :---- | :---- | +| Create a top-level domain | metalake (shared) → existing tag (exclusive) | metalake drop; two domains binding the same (tag, value) | +| Create a subdomain | metalake (shared) → parent (shared) → existing tag (exclusive) | the parent being attached elsewhere (C2) | +| Attach / move | child (exclusive) and new parent (shared), in ID order | the new parent being attached elsewhere (C2); a subdomain being created under the child (C3); crossing moves | +| Detach | child (exclusive) | | +| Publish | domain (exclusive) and parent (shared) | the parent being unpublished | +| Unpublish, delete | domain (exclusive) | a subdomain being created, attached or published under it | +| Set or remove the backing tag | domain (exclusive) → tag (exclusive) | a conflicting binding; the tag being deleted | +| Rename, alter | domain (exclusive) | | + +- Every name-addressed operation resolves the name to an ID, locks the row, then re-checks that the row still has that name and is not deleted; otherwise it fails with 404. +- A tag named in a request is re-checked after locking; a missing tag fails with 404 instead of being skipped. +- A new backing tag has no row to lock yet; its name is protected by the tag name's unique key, so a concurrent create with the same name fails with 409. +- The unique index on `DomainRel` and the domain row's own version (optimistic locking, never exposed to callers) remain as backstops. + +### Status + +- New domains are `DRAFT`. The owner or a `MANAGE_DOMAIN` holder publishes or unpublishes, at any time and without approval. +- **Visibility is the same on every surface** (REST, MCP, UI). A `DRAFT` domain is returned only to its owner and to `MANAGE_DOMAIN` holders on it, on its parent or on the metalake. Domain APIs do not return it to anyone else: lists omit it, get returns 404, and it is not in `list_domains`, member listings, an object's domains or a parent's roll-up. Responses carry `status`, so a caller that sees drafts can filter them out. +- **Known channels**: hiding a draft is not confidentiality. Its existence can still show through: + - creating a domain with the same name fails with 409; + - its backing tag is an ordinary tag, visible to tag readers and on tagged assets (use a non-descriptive tag name if that matters); + - `parent` is always returned, even when the caller cannot see the parent. Only the name is exposed; getting the parent still returns 404, and nothing can be done on it. +- **Consistency**: publishing a subdomain needs a published parent; unpublishing needs no published subdomains (C6 covers attach and move). +- Assets can be tagged into a `DRAFT` domain to prepare it; anyone with `APPLY_TAG` on its tag can do so. +- Roll-ups depend on the caller: someone who can see a `DRAFT` subdomain sees its members under the parent; others do not. + +### Membership and inheritance + +- **Rule**: an object's effective tags are its own plus its schema's and catalog's; for the same tag the nearest level wins, and different tags accumulate. A leaf object (see Member types) is a member of domain D when its effective tags match D's backing (tag, value). Membership reuses core's `EffectiveTagResolver`, which applies no tag-privilege filtering: domain visibility and object access are applied afterwards, so a caller needs no tag privilege. +- **Different tags accumulate**: with schema `finance` and table `sales`, the table is in both Finance and Sales, and a child cannot leave a domain it inherits. +- **Values of one tag** backing different domains override each other: with schema `dept=finance` and table `dept=hr`, the table is only in the `dept=hr` domain, unless it uses `dept=[finance, hr]`. This is the one case where a child can leave an inherited domain, and only for bindings with a value; a binding without a value matches every value. +- **Member types**: leaf objects only: tables, views, filesets, topics, models and semantic models (`DomainMember.SUPPORTED_TYPES`). Catalogs and schemas are never members: one that carries a backing tag passes membership to the leaf objects under it through tag inheritance. Columns and functions are not members either: a column or function carrying a backing tag is not listed. A type that becomes taggable later joins only when it is added to this list. Metrics belong through their semantic model. +- **Relations**: a leaf object relates to a domain in one of three ways, reported with its source by member listing and by domains of an object: + + | Relation | Meaning | Source | + | :---- | :---- | :---- | + | Direct | the object itself carries the domain's backing tag | none | + | Inherited | the object inherits the backing tag from its catalog or schema | the nearest catalog or schema carrying it | + | Via subdomain | a visible subdomain of the domain has the object in its scope | that subdomain | + + Every relation that applies is reported, as a list of (relation, source) pairs, with no precedence: a table whose schema carries `finance` and which itself carries `trading` gets `[Inherited from cat.db, Via subdomain Trading]` under Finance, and an object in two subdomains gets one Via subdomain pair for each. Clients decide how to display them. Relations are always relative to the listed domain. An object is a member of the domain itself when its relations include Direct or Inherited. The relation is an enum; the exact field shape is an implementation choice. +- **Domains of an object**: for a leaf object, every domain the caller can see whose scope contains the object, each with its relations. An object tagged only `trading` gets Trading (Direct) and Finance (Via subdomain Trading). Catalogs and schemas do not support this lookup, since they are never members; the domain a catalog or schema passes down is visible from its tags. +- **Removing a member**: membership ends when the object no longer carries the backing tag, by the tag rules. To take an object out of a domain backed by a tag without a value, remove the tag by name (v1 association request), which removes all its values on the object; a v2 request removes one (tag, value) pair. An inherited membership can only be removed on the catalog or schema. +- **Domain filters**: Phase 1 adds no domain filter to other list APIs. When one is added, it matches scope, consistent with member listing. + +### Member listing + +`GET /domains/{domain}/objects` lists leaf objects, expanding catalogs and schemas: + +1. Resolve the backing tags of the domains in the selected scopes (by default the listed domain's whole scope: itself and its visible subdomains). +2. List the objects carrying them (`listMetadataObjectsForTag`, by tag and value). Leaf objects are candidates as they are; a tagged catalog or schema is replaced by the leaf objects under it, from Gravitino's store. +3. Keep the candidates whose effective tags match a selected backing tag (the matching rules above, nearest level wins), so a leaf object that overrides an inherited value drops out. Only bindings with a value need this check. +4. Drop objects the caller cannot access (`filterMetadataObject`), de-duplicate, and record each member's relations and sources. + +- **A view of recorded objects**: membership is defined by the rule above; the listing shows the objects recorded in Gravitino's store. Objects in an external catalog that Gravitino has not recorded yet are not listed, so an empty listing does not mean an object is not a member. +- **Cost**: a domain backed by a tag on a large catalog lists every recorded leaf object under it (see Open Questions). +- **Per caller**: the listing depends on the subdomains and objects the caller can see, so its length is not a property of the domain. Paging and counts come in Phase 2; counts follow the listing and depend on the caller too, so they never reveal assets the caller cannot see. + +### Access control + +**Domain privileges** + +| Privilege | On the metalake | On a domain | +| :---- | :---- | :---- | +| `MANAGE_DOMAIN` | Create domains and manage all of them | Edit, publish, subdomains, backing tag, delete; see it while `DRAFT` | +| `USE_DOMAIN` | See all published domains and browse their members | See this domain and browse its members | + +`MANAGE_DOMAIN` includes `USE_DOMAIN`; owners and the metalake owner hold both. There is no separate `CREATE_DOMAIN`. Privileges on a domain, and its ownership, also apply to its subdomains; metalake-level grants cover every domain. + +- **Access follows the current parent**: moving or detaching a subdomain changes who can see and manage it, without any grant changing. After Trading moves from Finance to Markets, Finance's holders lose access to it and Markets' holders gain it. A Finance manager can detach Trading through that inherited privilege, and then has none on it. Move and detach events carry the old and new parent, so an auditor can reconstruct why someone had access. + +**What each operation needs** + +| Operation | Required | +| :---- | :---- | +| See a domain | Published: `USE_DOMAIN`. Draft: owner or `MANAGE_DOMAIN` | +| Browse members, domains of an object | See the domain; results filtered by each object's own privileges; no tag privilege | +| Add or remove members | Existing tag authorization (`CAN_ACCESS_METADATA_AND_TAG`): access to the object and `APPLY_TAG` on the backing tag. No domain privilege | +| Create with a new tag | `MANAGE_DOMAIN` and `CREATE_TAG` on the metalake | +| Create with an existing tag | `MANAGE_DOMAIN` on the metalake and ownership of the tag (or the metalake) | +| Create a subdomain | Also `MANAGE_DOMAIN` on the parent | +| Set or replace the backing tag | `MANAGE_DOMAIN` and ownership of the new tag (or the metalake) | +| Remove the backing tag | `MANAGE_DOMAIN` | +| Attach / move | `MANAGE_DOMAIN` on both the domain and the new parent | +| Detach | `MANAGE_DOMAIN` on the domain (holding it on the current parent is enough) | +| Publish, unpublish, edit, delete | `MANAGE_DOMAIN` | + +- **Membership is controlled by tag privileges only**: anyone with read access to an asset and `APPLY_TAG` on a backing tag can add the asset, without the asset owner's consent. This is the existing tag behavior; a steward controls membership by controlling `APPLY_TAG` on the backing tag. +- Browsing without `VIEW_TAG` is safe because only a tag's owner can bind it to a domain: exposing that one (tag, value) to the domain's viewers is the owner's decision, and viewers still see only objects they can access. +- A user with `MANAGE_DOMAIN` but no `APPLY_TAG` on the tag can manage the domain but not add members. + +**Implementation** + +- Every domain endpoint has a single `{domain}` path parameter. Domain names carry no parent, so the generic authorization expressions cannot derive it; the parent part of the check is done in the domain code: a privilege on the domain, or else on its current parent read from `DomainRel` (one lookup, batched when filtering a list of domains). The generic authorization framework is unchanged. +- A parent or backing tag named in a request body (`parent` on create and attach, `backingTag` on create and on `PUT .../backingTag`) is checked imperatively: `MANAGE_DOMAIN` on the new parent; for an existing tag, ownership of the tag (or the metalake); for a new tag, `CREATE_TAG` on the metalake. + +### Grants and policies + +- **No data-access grants on domains.** Since anyone with `APPLY_TAG` on a backing tag can add an asset, an allow grant on a domain would turn tagging into granting access without the asset owner's consent. Possible future designs are listed in Open Questions. +- **Policies follow tags only.** Domains add no policy mechanism, and there are no "domain policies". An asset is governed by the policies associated with the tags it carries, the backing tag included, under the existing tag-policy semantics (association selectors for all values or one value). A domain passes no policy to its members or to its subdomains: domains and assets have no containment relation. An asset listed under Finance only via subdomain Trading is not governed by the policies on Finance's backing tag. Conversely, tagging an asset with a backing tag brings it under that tag's policies, as for any tag, so `APPLY_TAG` on a backing tag has a governance effect as well. + +### REST API + +Under `/api/metalakes/{metalake}`, following Gravitino conventions: `?details=` on lists, `PUT` + updates to alter, `PATCH` + a set request to switch state, `DropResponse` on delete, owners through the owner API. + +| Operation | Endpoint | Request → response | +| :---- | :---- | :---- | +| List | `GET /domains?details=false` | → `NameListResponse` / `DomainListResponse` | +| Create | `POST /domains` | `DomainCreateRequest{name, comment, properties, backingTag{name, value, create}, parent}` → `DomainResponse` | +| Get | `GET /domains/{domain}` | → `DomainResponse` | +| Alter | `PUT /domains/{domain}` | `DomainUpdatesRequest{updates: [rename, updateComment, setProperty, removeProperty]}` → `DomainResponse` | +| Publish / unpublish | `PATCH /domains/{domain}` | `DomainSetRequest{status}` → `DomainResponse` | +| Drop | `DELETE /domains/{domain}` | → `DropResponse` (`false` if absent) | +| Attach / move | `PUT /domains/{domain}/parent` | `DomainParentSetRequest{parent}` → `DomainResponse` | +| Detach | `DELETE /domains/{domain}/parent` | → `DomainResponse` (no-op for a top-level domain) | +| Set / remove backing tag | `PUT /domains/{domain}/backingTag` | `DomainBackingTagSetRequest{backingTag{name, value}}` (null `backingTag` removes it) → `DomainResponse` | +| List subdomains | `GET /domains/{domain}/subdomains?details=false` | → `NameListResponse` / `DomainListResponse` | +| List members | `GET /domains/{domain}/objects` | → `DomainMemberListResponse` | +| Domains of an object | `GET /objects/{type}/{fullName}/domains?details=false` (leaf types only) | → `NameListResponse` / `DomainListResponse` | +| Add / remove members | Existing `POST /objects/{type}/{fullName}/tags` with the domain's backing tag | `TagValuesAssociateRequest` (v2) or `TagsAssociateRequest` (v1) | + +``` +DomainDTO { name, comment, properties, status, parent, backingTag{name, value}, audit, + relations? } // [{relation, source?}], only on domains of an object +DomainMemberDTO { object: MetadataObjectDTO, relations: [{relation, source?}] } +``` + +- **Create**: `backingTag.create = true` creates a new tag with the given name (no `value`); `false` binds the existing (tag, value). Domain, new tag and parent edge are written in one transaction; on any failure nothing is created, and no new tag is left behind. +- **Membership requests**: adding a member with a value needs the v2 `TagValuesAssociateRequest` (`Content-Type: application/vnd.gravitino.v2+json`), which carries (name, value) pairs. Removing a tag by name with the v1 `TagsAssociateRequest` removes all its values on the object. The Java and Python clients already support both. +- **Parent endpoints** address the child only, matching `Domain#attachToParent` and `Domain#detachFromParent`; detaching needs no parent name. +- **State changes return the domain**: publish, unpublish, attach, move, detach and backing-tag changes return the updated domain, because the Java API returns a new `Domain` snapshot from them. +- **Concurrency**: changes apply to the current state on the server and the last write wins, as for other Gravitino metadata operations; there is no version check. Detach removes the current parent edge and removing the backing tag removes the current one, whatever the caller's snapshot shows; detaching a top-level domain is a no-op, not an error. Domains are addressed by name, so if a domain is renamed and a new one takes its old name, later calls with that name act on the new domain. +- **Member listing parameters**: + - `domains` (repeatable, `?domains=Tax&domains=Trading`; not comma-separated, since domain names may contain `,`): each name, the listed domain itself or one of its visible subdomains, selects that domain's scope, and the result is the union. On Finance with subdomains Tax and Trading, not setting it or `domains=Finance` lists the whole scope (Finance, Tax and Trading), `domains=Tax` lists only Tax's members, and `domains=Tax&domains=Trading` lists Tax's and Trading's. Names that are neither the domain nor a subdomain visible to the caller are ignored; if all given names are ignored, the result is empty rather than everything, so a typo cannot widen it. + - `types` (comma-separated, each a leaf member type) and `nameContains` (case-insensitive, on the full name). Filters apply to the expanded leaf list, so `types=TABLE` finds the tables under a tagged catalog. + - The listed domain's own members are those whose relations include Direct or Inherited. + - No paging in Phase 1, like the other core list APIs; paging and counts come in Phase 2. +- **Errors**: + - 404: `NoSuchDomainException` (missing, or a `DRAFT` domain the caller cannot see); `NoSuchTagException` (a backing tag to bind that does not exist). + - 409: `DomainAlreadyExistsException` (create, or rename to an existing name); `NonEmptyDomainException` (drop with subdomains); `TagAlreadyExistsException` (a new backing tag whose name exists); `BackingTagAlreadyBoundException` (a binding that conflicts with another domain). + - 400: `IllegalArgumentException` (C1–C6, status consistency, a value the tag does not allow, a create request without a backing tag or with both `create` and a value, a `DOMAIN` target on the generic tag association API). + - 403: `ForbiddenException` (binding a tag the caller does not own, nor the metalake; creating a new tag without `CREATE_TAG`). +- **Encoding**: tag and domain names may contain `/`, which must be URL-encoded (`%2F`) in every path segment: `{tag}`, and `{domain}` in `/domains/{domain}`, `/objects/domain/{domain}/...`, `/owners/domain/{domain}` and `/permissions/roles/{role}/domain/{domain}/grant|revoke`. The Java, Python and MCP clients already encode path segments, and Gravitino's Jetty 9.4 / Jersey stack decodes them into the path parameters. An end-to-end test through the real server, with authentication and authorization enabled, will cover these paths, including filters that read the decoded path. Deployments behind a reverse proxy must pass `%2F` through without decoding it (for example, nginx `proxy_pass` without a URI part), and a later Jetty upgrade must keep ambiguous path separators allowed. +- **Reused**: owners at `/owners/domain/{domain}`; privileges through the role and permission APIs, with `DOMAIN` as a securable type. Review Comment: [P2] Cover the reused owner and grant endpoints in inheritance The existing [`hasSetOwnerPermission` and `hasMetadataPrivilegePermission` checks](https://github.com/apache/gravitino/blob/7365fe5c548cfb3f321bbdfccf3a2e38ee4639bf/server-common/src/main/java/org/apache/gravitino/server/authorization/jcasbin/JcasbinAuthorizer.java#L630-L755) walk `MetadataObjects.parent`, which cannot discover Finance as Trading's parent in `DomainRel`. If inheritance is implemented only in domain endpoints, Finance's owner can manage Trading but cannot exercise the promised inherited ownership through these owner and grant/revoke endpoints. Please specify how these reused checks consult the same current-parent resolver. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
