This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 8cbd3847d6 [docs] Add REST management OpenAPI (#9406)
8cbd3847d6 is described below
commit 8cbd3847d646b1c40e380f53b68a0cd97880b02e
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Aug 26 16:50:38 2026 +0800
[docs] Add REST management OpenAPI (#9406)
---
docs/README.md | 2 +-
docs/docs/concepts/rest/index.md | 3 +-
docs/docs/concepts/rest/management-api.md | 232 ++++++++
docs/scripts/validate-rest-openapi.js | 897 +++++++++++++++++++++++++-----
docs/sidebars.js | 3 +-
docs/static/rest-management-open-api.yaml | 852 ++++++++++++++++++++++++++++
6 files changed, 1853 insertions(+), 136 deletions(-)
diff --git a/docs/README.md b/docs/README.md
index 67d65ae18e..5e7ba5586c 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -22,7 +22,7 @@ The site will be available at
http://localhost:3000/docs/master/.
## Build
```bash
-# Validate the REST Catalog OpenAPI contract
+# Validate the REST OpenAPI contracts
yarn test:rest-openapi
# Production build
diff --git a/docs/docs/concepts/rest/index.md b/docs/docs/concepts/rest/index.md
index bd8ada4b67..57a3283831 100644
--- a/docs/docs/concepts/rest/index.md
+++ b/docs/docs/concepts/rest/index.md
@@ -63,4 +63,5 @@ RESTCatalog supports multiple access authentication methods,
including the follo
## REST Open API
-See [REST API](./rest-api).
+See [REST Catalog API](./rest-api) for catalog operations and
+[REST Management API](./management-api) for permission and data-policy
management.
diff --git a/docs/docs/concepts/rest/management-api.md
b/docs/docs/concepts/rest/management-api.md
new file mode 100644
index 0000000000..95892fa8a2
--- /dev/null
+++ b/docs/docs/concepts/rest/management-api.md
@@ -0,0 +1,232 @@
+---
+title: "REST Management API"
+hide_table_of_contents: true
+---
+
+<!--
+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.
+-->
+
+The REST Management API is an experimental OpenAPI 3.1 control-plane extension
for object
+privileges, row filters, and column masks in a Paimon REST Catalog. Its
current contract version is
+`1.0` and may evolve incompatibly while the design is being validated.
+
+`RESTCatalog` exposes `permissionManagement()` and `policyManagement()`
directly. These methods are
+intentionally not part of the generic `Catalog` interface. Other catalog
implementations do not
+expose this management contract.
+
+## Catalog addressing
+
+All management endpoints use the opaque `prefix` returned by the REST Catalog
config endpoint. It
+is not a catalog name in a payload and is independent of local engine catalog
aliases.
+
+```
+GET /v1/{prefix}/permissions
+POST /v1/{prefix}/permissions/grant
+POST /v1/{prefix}/permissions/revoke
+
+GET /v1/{prefix}/databases/{database}/tables/{table}/policies
+POST /v1/{prefix}/databases/{database}/tables/{table}/policies
+POST /v1/{prefix}/databases/{database}/tables/{table}/policies/drop
+```
+
+Policies are currently attached only to tables. The path is the attachment
identity, so policy
+request bodies do not repeat a catalog, database, table, or resource type.
Catalog- and
+database-level matching can be added later with explicit matching semantics
instead of implied
+path inheritance.
+
+The complete wire contract is available in
+[`rest-management-open-api.yaml`](/rest-management-open-api.yaml).
+
+## Privileges and policies are independent
+
+A permission grants one access on one resource to one principal. A data policy
restricts rows or
+columns visible through an already-authorized read. Creating a policy never
grants `SELECT`, and
+revoking `SELECT` does not delete policies.
+
+This separation also defines the expected query path:
+
+1. The server evaluates object privileges.
+2. The server resolves all row-filter and column-masking policies applicable
to the caller.
+3. The existing REST Catalog table authorization endpoint returns the stored
Paimon predicate and
+ column transforms to the engine.
+4. The engine applies those restrictions when planning the scan.
+
+Management payloads use the same serialized Paimon `Predicate` and `Transform`
representation as
+the existing `AuthTableQueryResponse`. Policy conflict detection, schema
validation, and principal
+resolution are server responsibilities.
+
+## Permission model
+
+Permission resources are structured objects:
+
+| Resource type | Required locator | Example |
+| --- | --- | --- |
+| `CATALOG` | none | `{"type":"CATALOG"}` |
+| `CATALOG_ALL` | none | `{"type":"CATALOG_ALL"}` |
+| `DATABASE` | `database` | `{"type":"DATABASE","database":"sales"}` |
+| `DATABASE_ALL` | `database` | `{"type":"DATABASE_ALL","database":"sales"}` |
+| `TABLE` | `database`, `table` |
`{"type":"TABLE","database":"sales","table":"orders"}` |
+| `COLUMN` | `database`, `table` |
`{"type":"COLUMN","database":"sales","table":"orders"}` |
+| `FUNCTION` | `database`, `function` |
`{"type":"FUNCTION","database":"sales","function":"calculate_tax"}` |
+| `VIEW` | `database`, `view` |
`{"type":"VIEW","database":"sales","view":"daily_orders"}` |
+
+Principals are opaque, canonical strings that are globally unique in the
server namespace. Their
+format is server-defined and may encode a user, group, role, or service
identity, for example
+`role:analyst` or an external identity-provider ARN. Principal type and
membership resolution are
+server responsibilities. Access values are limited to 32 characters and
principals to 128
+characters. An implementation may resolve wire locators and principals to
different stable
+persistence identifiers; those internal ids are not exposed by this API.
+
+The built-in accesses use a common data-authorization vocabulary. Creation
accesses intentionally
+use their persisted names without underscores:
+
+| Access | Meaning |
+| --- | --- |
+| `ALL` | All accesses applicable to the resource. |
+| `CREATEDATABASE` | Create a database in a catalog. |
+| `DESCRIBE` | Read database metadata or select the current database. |
+| `ALTER` | Modify resource metadata. |
+| `DROP` | Drop the resource. |
+| `CREATETABLE` | Create a table in a database. |
+| `CREATEFUNCTION` | Create a function in a database. |
+| `CREATEVIEW` | Create a view in a database. |
+| `LIST` | List resources in a database. |
+| `SELECT` | Read table or view data, or use a function. |
+| `UPDATE` | Write table data, including insert, update, and delete
operations. |
+| `GRANT` | Grant or revoke assignments on the resource. |
+
+Java helpers accept access names case-insensitively and normalize them before
sending. The REST
+wire format uses upper case. Built-in accesses are resource-specific:
+
+| Resource | Accesses |
+| --- | --- |
+| `CATALOG` | `ALL`, `ALTER`, `DROP`, `GRANT`, `CREATEDATABASE` |
+| `CATALOG_ALL` | `ALL`, `DESCRIBE`, `ALTER`, `DROP`, `GRANT`, `CREATETABLE`,
`CREATEVIEW`, `CREATEFUNCTION`, `LIST`, `SELECT`, `UPDATE` |
+| `DATABASE` | `ALL`, `DESCRIBE`, `ALTER`, `DROP`, `GRANT`, `CREATETABLE`,
`CREATEVIEW`, `CREATEFUNCTION`, `LIST` |
+| `DATABASE_ALL` | `ALL`, `SELECT`, `UPDATE`, `ALTER`, `DROP`, `GRANT` |
+| `TABLE` | `ALL`, `SELECT`, `UPDATE`, `ALTER`, `DROP`, `GRANT` |
+| `COLUMN` | `SELECT` |
+| `VIEW` | `ALL`, `SELECT`, `ALTER`, `DROP`, `GRANT` |
+| `FUNCTION` | `ALL`, `SELECT`, `ALTER`, `DROP`, `GRANT` |
+
+An assignment identity is `resource`, `access`, and `principal`. Granting the
same identity replaces
+its expiry, and revocation is idempotent. `CATALOG`, `DATABASE`, `TABLE`,
`COLUMN`, `VIEW`, and
+`FUNCTION` apply only to the exact referenced resource. `CATALOG_ALL` is an
explicit scope over the
+configured catalog's database, table, view, and function descendants;
`DATABASE_ALL` is an explicit
+scope over the named database's table, view, and function descendants. These
scope assignments also
+apply to descendants created later. They remain direct assignments in listing
responses; the server
+does not synthesize inherited assignments. Resolving group membership and role
inheritance remains a
+server responsibility.
+
+### Column permissions
+
+A column permission uses a `COLUMN` resource whose locator is the containing
table, `SELECT`
+access, and one `columns` object. Exactly one non-empty list is allowed:
+
+- `columnNames` is an allowlist. Only the named top-level columns are readable.
+- `excludedColumnNames` is a denylist. Every current top-level column except
the named columns is
+ readable.
+
+For example, this assignment allows only `order_id` and `region`:
+
+```json
+{
+ "resource": {
+ "type": "COLUMN",
+ "database": "sales",
+ "table": "orders"
+ },
+ "access": "SELECT",
+ "principal": "role:analyst",
+ "columns": {
+ "columnNames": ["order_id", "region"]
+ }
+}
+```
+
+The assignment identity remains `(resource, access, principal)`; `columns` is
not part of the
+identity. Granting the same identity replaces the entire previous allowlist or
denylist rather than
+merging individual names. Revocation therefore omits `columns` and removes the
whole column
+assignment.
+
+All named columns must exist when granted, and the table must enforce query
authorization. A server
+may enable `query-auth.enabled` atomically with the grant; otherwise it must
reject the grant. Column
+names refer only to top-level fields. For every effective caller principal,
applicable column ranges
+are intersected. If any applicable range rejects a selected column, the query
fails rather than
+silently dropping that column.
+
+Schema evolution keeps the assignment attached to the stable table identity.
Renaming a referenced
+column updates its stored name. Dropping a referenced column removes it from
the range; if that
+would leave the stored list empty, the assignment is removed. An allowlist
denies columns added
+later, while a denylist allows them, so allowlists are safer when new columns
may contain sensitive
+data.
+
+`expireTime`, when present, is an exclusive upper bound evaluated against the
REST server clock.
+At `now >= expireTime`, the assignment must not authorize access. Expired
direct assignments may
+remain visible in listings until server cleanup. Timestamps must not be more
precise than
+milliseconds; the wire value uses UTC `Z` and contains at most three
fractional digits.
+
+Resource objects in this API are wire locators, not persistence identities.
Servers must bind direct
+assignments to a stable internal resource identity: renaming a database,
table, function, or view
+retains its assignments and subsequent responses use the new locator; dropping
it removes its direct
+assignments; recreating the same locator does not restore them.
+
+## Data policy model
+
+A data policy is attached directly to one table and one principal. It applies
whenever that
+principal is effective for the caller after the server resolves group and role
membership. A
+principal can have at most one row filter on a table and at most one column
mask on each table
+column. A row-filter identity is `(table, ROW_FILTER, principal)`; a
column-mask identity is
+`(table, COLUMN_MASKING, principal, onColumn)`.
+
+Each policy contains exactly one typed definition:
+
+| Definition | Required fields | Result |
+| --- | --- | --- |
+| `rowFilter` | `predicate` | One serialized Paimon `Predicate`, applied to
every scan. |
+| `columnMask` | `onColumn`, `transform` | One serialized Paimon `Transform`
whose result replaces the protected column. |
+
+The common field is one `principal`. `rowFilter.predicate` maps directly to
one entry in
+`AuthTableQueryResponse.filter`. `columnMask.onColumn` and
`columnMask.transform` map directly to
+one key and value in `AuthTableQueryResponse.columnMasking`. Each JSON value
is limited to 60 KiB
+in UTF-8. This is Paimon's versioned serialization format rather than SQL text
or a portable policy
+DSL; clients and servers must use compatible Paimon versions.
+
+Policy creation must be rejected unless all of these conditions hold:
+
+1. The target database and table exist.
+2. The table has `query-auth.enabled=true`; otherwise a stored policy could be
silently bypassed.
+3. The referenced principal exists.
+4. The predicate or transform is recognized by the server, deserializes to a
non-null Paimon
+ object, and is canonicalized before storage.
+5. Every referenced field and `onColumn` exists in the target table, and a
transform's output type
+ matches its protected column.
+
+These invariants continue to apply for the whole table lifecycle. Servers must
bind policies to a
+stable table identity, preserve that binding across table renames, and remove
the policies when the
+table is dropped. A table with policies must reject changes that disable
`query-auth.enabled` or
+remove or rename a protected or referenced column, unless the policy update
and schema change are
+performed atomically. If an implementation persists all masks for one
principal in one document,
+creating or dropping one column mask must atomically preserve masks for other
columns.
+
+At authorization time, all applicable row filters must be combined with
logical `AND`. More than
+one applicable column mask targeting the same column must fail closed. An
invalid, unsupported, or
+schema-incompatible predicate or transform must also fail closed rather than
omit a restriction.
+
+This experimental contract deliberately does not define governed tags,
catalog/database policy
+inheritance, or tag-driven matching. Those features need explicit match
conditions and conflict
+rules before being added.
diff --git a/docs/scripts/validate-rest-openapi.js
b/docs/scripts/validate-rest-openapi.js
index 62e3598cf5..c4846178fb 100644
--- a/docs/scripts/validate-rest-openapi.js
+++ b/docs/scripts/validate-rest-openapi.js
@@ -20,8 +20,7 @@ const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
-const specPath = path.resolve(__dirname, '..', 'static',
'rest-catalog-open-api.yaml');
-const spec = yaml.load(fs.readFileSync(specPath, 'utf8'));
+const HTTP_METHODS = new Set(['get', 'post', 'put', 'delete', 'patch', 'head',
'options', 'trace']);
function check(condition, message) {
if (!condition) {
@@ -33,82 +32,181 @@ function decodePointerSegment(segment) {
return segment.replace(/~1/g, '/').replace(/~0/g, '~');
}
-function resolveLocalRef(ref) {
- check(ref.startsWith('#/'), `Only local OpenAPI references are supported,
found: ${ref}`);
- return ref
- .slice(2)
- .split('/')
- .map(decodePointerSegment)
- .reduce((current, segment) => {
- check(
- current && Object.prototype.hasOwnProperty.call(current, segment),
- `Unresolved OpenAPI reference: ${ref}`,
- );
- return current[segment];
- }, spec);
-}
+function validateCommon(fileName) {
+ const specPath = path.resolve(__dirname, '..', 'static', fileName);
+ const spec = yaml.load(fs.readFileSync(specPath, 'utf8'));
-function visit(value) {
- if (Array.isArray(value)) {
- value.forEach(visit);
- return;
+ function checkSpec(condition, message) {
+ check(condition, `${fileName}: ${message}`);
}
- if (!value || typeof value !== 'object') {
- return;
+
+ function resolveLocalRef(ref) {
+ checkSpec(ref.startsWith('#/'), `Only local OpenAPI references are
supported, found: ${ref}`);
+ return ref
+ .slice(2)
+ .split('/')
+ .map(decodePointerSegment)
+ .reduce((current, segment) => {
+ checkSpec(
+ current && Object.prototype.hasOwnProperty.call(current, segment),
+ `Unresolved OpenAPI reference: ${ref}`,
+ );
+ return current[segment];
+ }, spec);
}
- check(!Object.prototype.hasOwnProperty.call(value, 'nullable'), 'OpenAPI 3.1
schemas must not use nullable');
- if (typeof value.$ref === 'string') {
- resolveLocalRef(value.$ref);
+
+ function visit(value) {
+ if (Array.isArray(value)) {
+ value.forEach(visit);
+ return;
+ }
+ if (!value || typeof value !== 'object') {
+ return;
+ }
+ checkSpec(
+ !Object.prototype.hasOwnProperty.call(value, 'nullable'),
+ 'OpenAPI 3.1 schemas must not use nullable',
+ );
+ if (typeof value.$ref === 'string') {
+ resolveLocalRef(value.$ref);
+ }
+ Object.values(value).forEach(visit);
}
- Object.values(value).forEach(visit);
-}
-function schema(name) {
- const value = spec.components && spec.components.schemas &&
spec.components.schemas[name];
- check(value, `Missing OpenAPI schema: ${name}`);
- return value;
-}
+ function schema(name) {
+ const value = spec.components && spec.components.schemas &&
spec.components.schemas[name];
+ checkSpec(value, `Missing OpenAPI schema: ${name}`);
+ return value;
+ }
-function requireProperties(schemaName, names) {
- const properties = schema(schemaName).properties || {};
- names.forEach((name) => {
- check(properties[name], `Schema ${schemaName} is missing property:
${name}`);
- });
- return properties;
-}
+ function requireProperties(schemaName, names) {
+ const properties = schema(schemaName).properties || {};
+ names.forEach((name) => {
+ checkSpec(properties[name], `Schema ${schemaName} is missing property:
${name}`);
+ });
+ return properties;
+ }
+
+ function requireRequiredProperties(schemaName, names) {
+ const required = schema(schemaName).required || [];
+ names.forEach((name) => {
+ checkSpec(required.includes(name), `Schema ${schemaName} must require
property: ${name}`);
+ });
+ }
-function requireTypedIntegerProperties(schemaName, names) {
- const properties = requireProperties(schemaName, names);
- names.forEach((name) => {
- check(
- properties[name].type === 'integer' && properties[name].format ===
'int64',
- `Schema ${schemaName}.${name} must be an int64 integer`,
+ function requireTypedIntegerProperties(schemaName, names) {
+ const properties = requireProperties(schemaName, names);
+ names.forEach((name) => {
+ checkSpec(
+ properties[name].type === 'integer' && properties[name].format ===
'int64',
+ `Schema ${schemaName}.${name} must be an int64 integer`,
+ );
+ });
+ }
+
+ function requireSchemaReference(schemaName, composition,
referencedSchemaName) {
+ const references = schema(schemaName)[composition] || [];
+ const expected = `#/components/schemas/${referencedSchemaName}`;
+ checkSpec(
+ references.some((reference) => reference.$ref === expected),
+ `Schema ${schemaName}.${composition} is missing reference: ${expected}`,
);
- });
-}
+ }
+
+ function validatePathParameters(resourcePath, pathItem, operation) {
+ const templateNames = Array.from(resourcePath.matchAll(/\{([^}]+)\}/g),
(match) => match[1]);
+ const parameters = [...(pathItem.parameters || []),
...(operation.parameters || [])].map(
+ (parameter) => (parameter.$ref ? resolveLocalRef(parameter.$ref) :
parameter),
+ );
+ const pathParameters = parameters.filter((parameter) => parameter.in ===
'path');
+ templateNames.forEach((name) => {
+ const parameter = pathParameters.find((candidate) => candidate.name ===
name);
+ checkSpec(parameter, `Path ${resourcePath} is missing path parameter:
${name}`);
+ checkSpec(
+ parameter.required === true,
+ `Path parameter ${resourcePath}.${name} must be required`,
+ );
+ });
+ pathParameters.forEach((parameter) => {
+ checkSpec(
+ templateNames.includes(parameter.name),
+ `Path ${resourcePath} declares unused path parameter:
${parameter.name}`,
+ );
+ });
+ }
-function requireSchemaReference(schemaName, composition, referencedSchemaName)
{
- const references = schema(schemaName)[composition] || [];
- const expected = `#/components/schemas/${referencedSchemaName}`;
- check(
- references.some((reference) => reference.$ref === expected),
- `Schema ${schemaName}.${composition} is missing reference: ${expected}`,
+ checkSpec(spec.openapi === '3.1.1', `Expected OpenAPI 3.1.1, found:
${spec.openapi}`);
+ checkSpec(
+ spec.paths && spec.components && spec.components.schemas,
+ 'Incomplete OpenAPI document',
);
+ visit(spec);
+
+ const operations = new Map();
+ for (const [resourcePath, pathItem] of Object.entries(spec.paths)) {
+ for (const [method, operation] of Object.entries(pathItem)) {
+ if (!HTTP_METHODS.has(method)) {
+ continue;
+ }
+ validatePathParameters(resourcePath, pathItem, operation);
+ checkSpec(
+ operation.operationId,
+ `Operation ${method.toUpperCase()} ${resourcePath} has no operationId`,
+ );
+ checkSpec(
+ !operations.has(operation.operationId),
+ `Duplicate operationId: ${operation.operationId}`,
+ );
+ operations.set(operation.operationId, operation);
+ }
+ }
+
+ function requireOperation(operationId) {
+ const operation = operations.get(operationId);
+ checkSpec(operation, `Missing operationId: ${operationId}`);
+ return operation;
+ }
+
+ function requireResponses(operationId, statusCodes) {
+ const responses = requireOperation(operationId).responses || {};
+ statusCodes.forEach((statusCode) => {
+ checkSpec(
+ responses[statusCode],
+ `Operation ${operationId} is missing response: ${statusCode}`,
+ );
+ });
+ }
+
+ return {
+ spec,
+ operations,
+ schema,
+ requireOperation,
+ requireProperties,
+ requireRequiredProperties,
+ requireTypedIntegerProperties,
+ requireSchemaReference,
+ requireResponses,
+ checkSpec,
+ };
}
-function requireArrayOfIdentifiers(schemaName, propertyName) {
- const properties = requireProperties(schemaName, [propertyName]);
- check(properties[propertyName].type === 'array', `Schema
${schemaName}.${propertyName} must be an array`);
- check(
+function requireArrayOfIdentifiers(contract, schemaName, propertyName) {
+ const properties = contract.requireProperties(schemaName, [propertyName]);
+ contract.checkSpec(
+ properties[propertyName].type === 'array',
+ `Schema ${schemaName}.${propertyName} must be an array`,
+ );
+ contract.checkSpec(
properties[propertyName].items &&
properties[propertyName].items.$ref ===
'#/components/schemas/Identifier',
`Schema ${schemaName}.${propertyName} items must reference Identifier`,
);
}
-function requireNullableStringProperty(schemaName, propertyName) {
- const property = requireProperties(schemaName, [propertyName])[propertyName];
- check(
+function requireNullableStringProperty(contract, schemaName, propertyName) {
+ const property = contract.requireProperties(schemaName,
[propertyName])[propertyName];
+ contract.checkSpec(
Array.isArray(property.type) &&
property.type.includes('string') &&
property.type.includes('null'),
@@ -116,85 +214,618 @@ function requireNullableStringProperty(schemaName,
propertyName) {
);
}
-check(spec.openapi === '3.1.1', `Expected OpenAPI 3.1.1, found:
${spec.openapi}`);
-check(spec.paths && spec.components && spec.components.schemas, 'Incomplete
OpenAPI document');
-visit(spec);
+function requireExactEnum(contract, schemaName, expectedValues) {
+ const actualValues = contract.schema(schemaName).enum || [];
+ contract.checkSpec(
+ actualValues.length === expectedValues.length &&
+ expectedValues.every((value) => actualValues.includes(value)),
+ `Schema ${schemaName} must define enum values: ${expectedValues.join(',
')}`,
+ );
+}
-const operationIds = new Set();
-for (const pathItem of Object.values(spec.paths)) {
- for (const operation of Object.values(pathItem)) {
- if (!operation || typeof operation !== 'object' || !operation.operationId)
{
- continue;
- }
- check(!operationIds.has(operation.operationId), `Duplicate operationId:
${operation.operationId}`);
- operationIds.add(operation.operationId);
- }
+function validateCatalogOpenApi() {
+ const contract = validateCommon('rest-catalog-open-api.yaml');
+ [
+ 'getConfig',
+ 'createDatabase',
+ 'getDatabase',
+ 'alterDatabase',
+ 'dropDatabase',
+ 'createTable',
+ 'getTable',
+ 'alterTable',
+ 'dropTable',
+ ].forEach(contract.requireOperation);
+
+ contract.requireProperties('ConfigResponse', ['defaults', 'overrides']);
+ contract.requireProperties('CreateDatabaseRequest', ['name', 'options']);
+ contract.requireProperties('AlterDatabaseRequest', ['removals', 'updates']);
+ contract.requireProperties('CreateTableRequest', ['identifier', 'schema']);
+ contract.requireProperties('AlterTableRequest', ['changes']);
+ contract.requireProperties('Identifier', ['database', 'object']);
+ contract.requireProperties('Schema', [
+ 'fields',
+ 'partitionKeys',
+ 'primaryKeys',
+ 'options',
+ 'comment',
+ ]);
+ contract.requireProperties('DataField', ['id', 'name', 'type',
'description', 'defaultValue']);
+
+ contract.requireSchemaReference('DataType', 'oneOf', 'VectorType');
+ contract.requireProperties('VectorType', ['type', 'element', 'length']);
+ contract.requireSchemaReference('SchemaChange', 'anyOf', 'DropPrimaryKey');
+ contract.checkSpec(
+ contract.schema('BaseSchemaChange').discriminator.mapping.dropPrimaryKey
===
+ '#/components/schemas/DropPrimaryKey',
+ 'BaseSchemaChange discriminator is missing dropPrimaryKey',
+ );
+ const dropPrimaryKey = contract.requireProperties('DropPrimaryKey',
['action']);
+ contract.checkSpec(
+ dropPrimaryKey.action.const === 'dropPrimaryKey',
+ 'Schema DropPrimaryKey.action must be dropPrimaryKey',
+ );
+ contract.checkSpec(
+ contract.schema('BaseInstant').discriminator.propertyName === 'type',
+ 'BaseInstant discriminator must use the JSON field type',
+ );
+
+ const updateViewComment = contract.requireProperties('UpdateViewComment',
['action', 'comment']);
+ contract.checkSpec(
+ !updateViewComment.key,
+ 'Schema UpdateViewComment must use comment instead of key',
+ );
+ ['UpdateComment', 'UpdateViewComment',
'UpdateFunctionComment'].forEach((schemaName) =>
+ requireNullableStringProperty(contract, schemaName, 'comment'),
+ );
+
+ const errorResourceTypes =
+ contract.requireProperties('ErrorResponse',
['resourceType']).resourceType.enum || [];
+ ['FUNCTION', 'DEFINITION'].forEach((resourceType) => {
+ contract.checkSpec(
+ errorResourceTypes.includes(resourceType),
+ `Schema ErrorResponse.resourceType is missing value: ${resourceType}`,
+ );
+ });
+
+ requireArrayOfIdentifiers(contract, 'ListTablesGloballyResponse', 'tables');
+ requireArrayOfIdentifiers(contract, 'ListViewsGloballyResponse', 'views');
+ requireArrayOfIdentifiers(contract, 'ListFunctionsGloballyResponse',
'functions');
+ contract.requireProperties('ListFunctionsGloballyResponse',
['nextPageToken']);
+ const getFunctionProperties =
contract.requireProperties('GetFunctionResponse', ['uuid']);
+ contract.checkSpec(
+ getFunctionProperties.uuid.type === 'string',
+ 'Schema GetFunctionResponse.uuid must be a string',
+ );
+
+ ['GetDatabaseResponse', 'GetTableResponse', 'GetViewResponse',
'GetFunctionResponse'].forEach(
+ (schemaName) => contract.requireTypedIntegerProperties(schemaName,
['createdAt', 'updatedAt']),
+ );
+ return contract.operations.size;
}
-[
- 'getConfig',
- 'createDatabase',
- 'getDatabase',
- 'alterDatabase',
- 'dropDatabase',
- 'createTable',
- 'getTable',
- 'alterTable',
- 'dropTable',
-].forEach((operationId) => {
- check(operationIds.has(operationId), `Missing provider-facing operationId:
${operationId}`);
-});
-
-requireProperties('ConfigResponse', ['defaults', 'overrides']);
-requireProperties('CreateDatabaseRequest', ['name', 'options']);
-requireProperties('AlterDatabaseRequest', ['removals', 'updates']);
-requireProperties('CreateTableRequest', ['identifier', 'schema']);
-requireProperties('AlterTableRequest', ['changes']);
-requireProperties('Identifier', ['database', 'object']);
-requireProperties('Schema', ['fields', 'partitionKeys', 'primaryKeys',
'options', 'comment']);
-requireProperties('DataField', ['id', 'name', 'type', 'description',
'defaultValue']);
-
-requireSchemaReference('DataType', 'oneOf', 'VectorType');
-requireProperties('VectorType', ['type', 'element', 'length']);
-requireSchemaReference('SchemaChange', 'anyOf', 'DropPrimaryKey');
-check(
- schema('BaseSchemaChange').discriminator.mapping.dropPrimaryKey ===
- '#/components/schemas/DropPrimaryKey',
- 'BaseSchemaChange discriminator is missing dropPrimaryKey',
-);
-const dropPrimaryKey = requireProperties('DropPrimaryKey', ['action']);
-check(
- dropPrimaryKey.action.const === 'dropPrimaryKey',
- 'Schema DropPrimaryKey.action must be dropPrimaryKey',
-);
-check(
- schema('BaseInstant').discriminator.propertyName === 'type',
- 'BaseInstant discriminator must use the JSON field type',
-);
+function validateManagementOpenApi() {
+ const contract = validateCommon('rest-management-open-api.yaml');
+ const operationIds = [
+ 'listPermissions',
+ 'grantPermission',
+ 'revokePermission',
+ 'listTablePolicies',
+ 'createTablePolicy',
+ 'dropTablePolicy',
+ ];
+ const resourcePaths = [
+ '/v1/{prefix}/permissions',
+ '/v1/{prefix}/permissions/grant',
+ '/v1/{prefix}/permissions/revoke',
+ '/v1/{prefix}/databases/{database}/tables/{table}/policies',
+ '/v1/{prefix}/databases/{database}/tables/{table}/policies/drop',
+ ];
-const updateViewComment = requireProperties('UpdateViewComment', ['action',
'comment']);
-check(!updateViewComment.key, 'Schema UpdateViewComment must use comment
instead of key');
-['UpdateComment', 'UpdateViewComment',
'UpdateFunctionComment'].forEach((schemaName) =>
- requireNullableStringProperty(schemaName, 'comment'),
-);
+ resourcePaths.forEach((resourcePath) =>
+ contract.checkSpec(
+ contract.spec.paths[resourcePath],
+ `Missing management path: ${resourcePath}`,
+ ),
+ );
+ [
+ '/v1/{prefix}/policies',
+ '/v1/{prefix}/databases/{database}/policies',
+ '/v1/{prefix}/databases/{database}/tables/{table}/policies/{policyName}',
+ ].forEach((resourcePath) =>
+ contract.checkSpec(
+ !contract.spec.paths[resourcePath],
+ `Policies must not be attachable outside tables: ${resourcePath}`,
+ ),
+ );
+ operationIds.forEach(contract.requireOperation);
+ ['listPermissions', 'grantPermission',
'revokePermission'].forEach((operationId) =>
+ contract.requireResponses(operationId, [
+ '200',
+ '400',
+ '401',
+ '403',
+ '404',
+ '429',
+ '500',
+ '503',
+ ]),
+ );
+ contract.requireResponses('grantPermission', ['409']);
+ ['listTablePolicies', 'createTablePolicy'].forEach((operationId) =>
+ contract.requireResponses(operationId, [
+ '200',
+ '400',
+ '401',
+ '403',
+ '404',
+ '429',
+ '500',
+ '503',
+ ]),
+ );
+ contract.requireResponses('dropTablePolicy', [
+ '200',
+ '400',
+ '401',
+ '403',
+ '404',
+ '429',
+ '500',
+ '503',
+ ]);
+ contract.requireResponses('createTablePolicy', ['409']);
+ contract.checkSpec(
+ contract.spec.info.version === '1.0' &&
+ contract.spec.info.description.toLowerCase().includes('experimental'),
+ 'The management contract must be versioned 1.0 and marked experimental',
+ );
+ contract.checkSpec(
+ !Object.prototype.hasOwnProperty.call(contract.spec, 'security'),
+ 'The management contract must not require one deployment-specific
authentication scheme',
+ );
-const errorResourceTypes = requireProperties('ErrorResponse',
['resourceType']).resourceType.enum || [];
-['FUNCTION', 'DEFINITION'].forEach((resourceType) => {
- check(
- errorResourceTypes.includes(resourceType),
- `Schema ErrorResponse.resourceType is missing value: ${resourceType}`,
+ const assignmentFields = ['resource', 'access', 'principal', 'columns',
'expireTime'];
+ contract.requireProperties('PermissionAssignment', assignmentFields);
+ contract.requireRequiredProperties('PermissionAssignment', ['resource',
'access', 'principal']);
+ const grantProperties = contract.requireProperties('GrantPermissionRequest',
[
+ 'resource',
+ 'access',
+ 'principal',
+ 'columns',
+ 'expireTime',
+ ]);
+ contract.requireRequiredProperties('GrantPermissionRequest', [
+ 'resource',
+ 'access',
+ 'principal',
+ ]);
+ ['policy', 'grantOption'].forEach((field) =>
+ contract.checkSpec(
+ !grantProperties[field],
+ `Schema GrantPermissionRequest must omit field: ${field}`,
+ ),
+ );
+ const revokeProperties =
contract.requireProperties('RevokePermissionRequest', [
+ 'resource',
+ 'access',
+ 'principal',
+ ]);
+ contract.requireRequiredProperties('RevokePermissionRequest', [
+ 'resource',
+ 'access',
+ 'principal',
+ ]);
+ ['expireTime', 'columns', 'policy', 'grantOption'].forEach((field) =>
+ contract.checkSpec(
+ !revokeProperties[field],
+ `Schema RevokePermissionRequest must omit field: ${field}`,
+ ),
);
-});
-requireArrayOfIdentifiers('ListTablesGloballyResponse', 'tables');
-requireArrayOfIdentifiers('ListViewsGloballyResponse', 'views');
-requireArrayOfIdentifiers('ListFunctionsGloballyResponse', 'functions');
-requireProperties('ListFunctionsGloballyResponse', ['nextPageToken']);
-const getFunctionProperties = requireProperties('GetFunctionResponse',
['uuid']);
-check(getFunctionProperties.uuid.type === 'string', 'Schema
GetFunctionResponse.uuid must be a string');
+ const permissionList = contract.requireProperties('ListPermissionsResponse',
[
+ 'permissions',
+ 'nextPageToken',
+ ]);
+ contract.checkSpec(
+ permissionList.permissions.type === 'array' &&
+ permissionList.permissions.items.$ref ===
'#/components/schemas/PermissionAssignment',
+ 'ListPermissionsResponse.permissions must contain PermissionAssignment
values',
+ );
+ contract.requireRequiredProperties('ListPermissionsResponse',
['permissions']);
-['GetDatabaseResponse', 'GetTableResponse', 'GetViewResponse',
'GetFunctionResponse'].forEach(
- (schemaName) => requireTypedIntegerProperties(schemaName, ['createdAt',
'updatedAt']),
-);
+ const principal = contract.schema('Principal');
+ contract.checkSpec(
+ principal.type === 'string' && principal.minLength === 1 &&
principal.maxLength === 128,
+ 'Principal must be a non-empty string that fits the 128-character
persistence identity',
+ );
+ contract.checkSpec(
+ !contract.spec.components.schemas.PrincipalRef &&
+ !contract.spec.components.schemas.PrincipalType &&
+ !contract.spec.components.parameters.PrincipalTypeQuery,
+ 'Principal must not expose a separate reference object or type',
+ );
+ ['PermissionAssignment', 'GrantPermissionRequest',
'RevokePermissionRequest'].forEach(
+ (schemaName) =>
+ contract.checkSpec(
+ contract.requireProperties(schemaName, ['principal']).principal.$ref
===
+ '#/components/schemas/Principal',
+ `Schema ${schemaName}.principal must reference Principal`,
+ ),
+ );
+ contract.checkSpec(
+ contract.spec.components.parameters.PrincipalQuery.schema.$ref ===
+ '#/components/schemas/Principal',
+ 'PrincipalQuery must reference Principal',
+ );
+ requireExactEnum(contract, 'ResourceType', [
+ 'CATALOG',
+ 'CATALOG_ALL',
+ 'DATABASE',
+ 'DATABASE_ALL',
+ 'TABLE',
+ 'COLUMN',
+ 'FUNCTION',
+ 'VIEW',
+ ]);
+ requireExactEnum(contract, 'PolicyType', ['ROW_FILTER', 'COLUMN_MASKING']);
+ Object.entries({
+ CatalogResource: 'CATALOG',
+ CatalogAllResource: 'CATALOG_ALL',
+ DatabaseResource: 'DATABASE',
+ DatabaseAllResource: 'DATABASE_ALL',
+ TableResource: 'TABLE',
+ ColumnResource: 'COLUMN',
+ FunctionResource: 'FUNCTION',
+ ViewResource: 'VIEW',
+ RowFilterPolicyIdentity: 'ROW_FILTER',
+ ColumnMaskPolicyIdentity: 'COLUMN_MASKING',
+ TablePolicyResource: 'TABLE',
+ }).forEach(([schemaName, expectedType]) => {
+ const typeProperty = contract.requireProperties(schemaName, ['type']).type;
+ contract.checkSpec(
+ typeProperty.type === 'string' && typeProperty.const === expectedType,
+ `${schemaName}.type must be a typed string constant`,
+ );
+ });
+
+ const permissionAccess = contract.schema('PermissionAccess');
+ const expectedAccesses = [
+ 'ALL',
+ 'CREATEDATABASE',
+ 'DESCRIBE',
+ 'ALTER',
+ 'DROP',
+ 'CREATETABLE',
+ 'CREATEFUNCTION',
+ 'CREATEVIEW',
+ 'LIST',
+ 'SELECT',
+ 'UPDATE',
+ 'GRANT',
+ ];
+ contract.checkSpec(
+ permissionAccess.type === 'string' &&
+ permissionAccess.enum.length === expectedAccesses.length &&
+ expectedAccesses.every((access) =>
permissionAccess.enum.includes(access)),
+ 'PermissionAccess must define the complete data access enum',
+ );
+ contract.checkSpec(
+ permissionAccess.maxLength === 32,
+ 'PermissionAccess must fit the 32-character persistence field',
+ );
+ ['PermissionAssignment', 'GrantPermissionRequest',
'RevokePermissionRequest'].forEach(
+ (schemaName) =>
+ contract.checkSpec(
+ contract.requireProperties(schemaName, ['access']).access.$ref ===
+ '#/components/schemas/PermissionAccess',
+ `Schema ${schemaName}.access must reference PermissionAccess`,
+ ),
+ );
+ contract.checkSpec(
+ contract.spec.components.parameters.AccessQuery.schema.$ref ===
+ '#/components/schemas/PermissionAccess',
+ 'AccessQuery must reference PermissionAccess',
+ );
+ contract.requireSchemaReference('PermissionResource', 'oneOf',
'ColumnResource');
+ contract.requireSchemaReference('PermissionResource', 'oneOf',
'CatalogAllResource');
+ contract.requireSchemaReference('PermissionResource', 'oneOf',
'DatabaseAllResource');
+ const catalogAllResource = contract.requireProperties('CatalogAllResource',
['type']);
+ contract.requireRequiredProperties('CatalogAllResource', ['type']);
+ contract.checkSpec(
+ catalogAllResource.type.const === 'CATALOG_ALL',
+ 'CatalogAllResource must use the CATALOG_ALL discriminator',
+ );
+ const databaseAllResource =
contract.requireProperties('DatabaseAllResource', [
+ 'type',
+ 'database',
+ ]);
+ contract.requireRequiredProperties('DatabaseAllResource', ['type',
'database']);
+ contract.checkSpec(
+ databaseAllResource.type.const === 'DATABASE_ALL',
+ 'DatabaseAllResource must use the DATABASE_ALL discriminator',
+ );
+ const columnResource = contract.requireProperties('ColumnResource', [
+ 'type',
+ 'database',
+ 'table',
+ ]);
+ contract.requireRequiredProperties('ColumnResource', ['type', 'database',
'table']);
+ contract.checkSpec(
+ columnResource.type.const === 'COLUMN',
+ 'ColumnResource must use the COLUMN discriminator',
+ );
+ const permissionColumns = contract.requireProperties('PermissionColumns', [
+ 'columnNames',
+ 'excludedColumnNames',
+ ]);
+ const permissionColumnsDescription = contract
+ .schema('PermissionColumns')
+ .description.toLowerCase()
+ .replace(/\s+/g, ' ');
+ ['intersected', 'fails the query', 'query authorization'].forEach((phrase) =>
+ contract.checkSpec(
+ permissionColumnsDescription.includes(phrase),
+ `PermissionColumns semantics are missing: ${phrase}`,
+ ),
+ );
+ ['columnNames', 'excludedColumnNames'].forEach((field) => {
+ const definition = permissionColumns[field];
+ contract.checkSpec(
+ definition.type === 'array' &&
+ definition.minItems === 1 &&
+ definition.uniqueItems === true &&
+ definition.items.type === 'string' &&
+ definition.items.minLength === 1,
+ `PermissionColumns.${field} must be a non-empty unique string array`,
+ );
+ });
+ const columnAlternatives = contract.schema('PermissionColumns').oneOf || [];
+ ['columnNames', 'excludedColumnNames'].forEach((field) =>
+ contract.checkSpec(
+ columnAlternatives.some(
+ (alternative) =>
+ alternative.required &&
+ alternative.required.length === 1 &&
+ alternative.required[0] === field,
+ ),
+ `PermissionColumns must define the ${field} alternative`,
+ ),
+ );
+ ['PermissionAssignment', 'GrantPermissionRequest'].forEach((schemaName) => {
+ const properties = contract.requireProperties(schemaName, ['columns']);
+ contract.checkSpec(
+ properties.columns.$ref === '#/components/schemas/PermissionColumns',
+ `${schemaName}.columns must reference PermissionColumns`,
+ );
+ contract.requireSchemaReference(schemaName, 'allOf',
'ColumnAssignmentConstraint');
+ });
+ ['DatabaseQuery', 'TableQuery', 'FunctionQuery',
'ViewQuery'].forEach((parameterName) => {
+ const locatorQuery = contract.spec.components.parameters[parameterName];
+ contract.checkSpec(
+ locatorQuery.schema.type === 'string' && locatorQuery.schema.minLength
=== 1,
+ `${parameterName} must be a non-empty string`,
+ );
+ });
+
+ ['TooManyRequests', 'ServiceUnavailable'].forEach((responseName) => {
+ const response = contract.spec.components.responses[responseName];
+ contract.checkSpec(response, `Missing reusable response: ${responseName}`);
+ contract.checkSpec(
+ response.headers['Retry-After'].$ref ===
'#/components/headers/RetryAfter',
+ `Response ${responseName} must expose the optional Retry-After header`,
+ );
+ });
+ contract.checkSpec(
+ contract.spec.components.headers.RetryAfter.schema.type === 'string',
+ 'RetryAfter must allow HTTP delta-seconds or an HTTP date as a string',
+ );
+
+ const expireTime = contract.schema('ExpireTime');
+ contract.checkSpec(
+ expireTime.type === 'string' &&
+ expireTime.format === 'date-time' &&
+ expireTime.pattern ===
'^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,3})?Z$',
+ 'ExpireTime must use UTC Z with at most three fractional digits',
+ );
+ ['PermissionAssignment', 'GrantPermissionRequest'].forEach((schemaName) => {
+ const expiry = contract.requireProperties(schemaName,
['expireTime']).expireTime;
+ contract.checkSpec(
+ expiry.$ref === '#/components/schemas/ExpireTime',
+ `${schemaName}.expireTime must reference the shared ExpireTime schema`,
+ );
+ });
+ const expireTimePattern = new RegExp(expireTime.pattern);
+ ['2027-01-01T00:00:00Z', '2027-01-01T00:00:00.1Z',
'2027-01-01T00:00:00.123Z'].forEach(
+ (value) =>
+ contract.checkSpec(expireTimePattern.test(value), `ExpireTime must
accept ${value}`),
+ );
+ ['2027-01-01T00:00:00.123456Z', '2027-01-01T00:00:00+00:00'].forEach((value)
=>
+ contract.checkSpec(!expireTimePattern.test(value), `ExpireTime must reject
${value}`),
+ );
+ const expiryDescription = expireTime.description.toLowerCase();
+ contract.checkSpec(
+ expiryDescription.includes('exclusive') &&
+ expiryDescription.includes('server clock') &&
+ expiryDescription.includes('millisecond') &&
+ expiryDescription.includes('must not authorize'),
+ 'expireTime must define exclusive millisecond server-clock authorization
semantics',
+ );
+ const resourceDescription =
contract.schema('PermissionResource').description.toLowerCase();
+ ['stable internal resource identity', 'renaming', 'dropping',
'recreating'].forEach((phrase) =>
+ contract.checkSpec(
+ resourceDescription.includes(phrase),
+ `PermissionResource lifecycle is missing: ${phrase}`,
+ ),
+ );
+ const policyDescription =
contract.schema('DataPolicy').description.toLowerCase();
+ ['logical and', 'same column', 'fail closed'].forEach((phrase) =>
+ contract.checkSpec(
+ policyDescription.includes(phrase),
+ `DataPolicy composition is missing: ${phrase}`,
+ ),
+ );
-console.log(`Validated REST OpenAPI contract with ${operationIds.size}
operations.`);
+ contract.requireSchemaReference('PolicyRequest', 'oneOf',
'RowFilterPolicyRequest');
+ contract.requireSchemaReference('PolicyRequest', 'oneOf',
'ColumnMaskPolicyRequest');
+ contract.requireSchemaReference('DataPolicy', 'oneOf',
'RowFilterDataPolicy');
+ contract.requireSchemaReference('DataPolicy', 'oneOf',
'ColumnMaskDataPolicy');
+ const rowFilter = contract.requireProperties('RowFilter', ['predicate']);
+ const columnMask = contract.requireProperties('ColumnMask', ['onColumn',
'transform']);
+ contract.requireRequiredProperties('RowFilter', ['predicate']);
+ contract.requireRequiredProperties('ColumnMask', ['onColumn', 'transform']);
+ [rowFilter.predicate, columnMask.transform].forEach((definition) => {
+ contract.checkSpec(
+ definition.type === 'string' &&
+ definition.minLength === 1 &&
+ definition['x-maxUtf8Bytes'] === 61440 &&
+ definition.contentMediaType === 'application/json',
+ 'Policy definitions must be bounded non-empty JSON strings',
+ );
+ });
+ ['RowFilterPolicyRequest', 'ColumnMaskPolicyRequest'].forEach((schemaName)
=> {
+ const properties = contract.requireProperties(schemaName, ['principal']);
+ contract.checkSpec(
+ properties.principal.$ref === '#/components/schemas/Principal',
+ `${schemaName}.principal must reference Principal`,
+ );
+ contract.checkSpec(
+ !properties.type &&
+ !properties.resource &&
+ !properties.name &&
+ !properties.toPrincipals &&
+ !properties.exceptPrincipals,
+ `${schemaName} must expose only one principal and no path identity`,
+ );
+ contract.requireRequiredProperties(schemaName, ['principal']);
+ });
+ contract.requireProperties('RowFilterPolicyRequest', ['rowFilter']);
+ contract.requireProperties('ColumnMaskPolicyRequest', ['columnMask']);
+ contract.requireRequiredProperties('RowFilterPolicyRequest', ['rowFilter']);
+ contract.requireRequiredProperties('ColumnMaskPolicyRequest',
['columnMask']);
+ contract.requireSchemaReference('DropPolicyRequest', 'oneOf',
'RowFilterPolicyIdentity');
+ contract.requireSchemaReference('DropPolicyRequest', 'oneOf',
'ColumnMaskPolicyIdentity');
+ contract.requireRequiredProperties('RowFilterPolicyIdentity', ['type',
'principal']);
+ contract.requireRequiredProperties('ColumnMaskPolicyIdentity', [
+ 'type',
+ 'principal',
+ 'column',
+ ]);
+ const tablePolicyResource =
contract.requireProperties('TablePolicyResource', [
+ 'type',
+ 'database',
+ 'table',
+ ]);
+ contract.checkSpec(
+ tablePolicyResource.type.const === 'TABLE',
+ 'Data policies must use a TABLE attachment resource',
+ );
+ const policyList = contract.requireProperties('ListPoliciesResponse', [
+ 'policies',
+ 'nextPageToken',
+ ]);
+ contract.checkSpec(
+ policyList.policies.items.$ref === '#/components/schemas/DataPolicy',
+ 'ListPoliciesResponse.policies must contain DataPolicy values',
+ );
+ ['RowFilterDataPolicy', 'ColumnMaskDataPolicy'].forEach((schemaName) => {
+ const properties = contract.requireProperties(schemaName, ['resource',
'principal']);
+ contract.checkSpec(
+ properties.principal.$ref === '#/components/schemas/Principal',
+ `${schemaName}.principal must reference Principal`,
+ );
+ contract.checkSpec(
+ !properties.name && !properties.toPrincipals &&
!properties.exceptPrincipals,
+ `${schemaName} must use a single principal identity`,
+ );
+ });
+ contract.requireProperties('ErrorResponse', [
+ 'message',
+ 'resourceType',
+ 'resourceName',
+ 'code',
+ ]);
+ contract.requireRequiredProperties('ErrorResponse', ['message', 'code']);
+
+ const permissionParameters = contract
+ .requireOperation('listPermissions')
+ .parameters.map((parameter) =>
+ parameter.$ref ? parameter.$ref.split('/').pop() : parameter.name,
+ );
+ [
+ 'ResourceTypeQuery',
+ 'DatabaseQuery',
+ 'TableQuery',
+ 'FunctionQuery',
+ 'ViewQuery',
+ 'PrincipalQuery',
+ 'AccessQuery',
+ 'PageToken',
+ 'MaxResults',
+ ].forEach((name) =>
+ contract.checkSpec(
+ permissionParameters.includes(name),
+ `Operation listPermissions is missing query parameter: ${name}`,
+ ),
+ );
+ contract.checkSpec(
+ permissionParameters.length === 9,
+ 'Operation listPermissions must expose only exact-resource filters and
pagination',
+ );
+ contract.checkSpec(
+ contract.spec.components.parameters.ResourceTypeQuery.required === true,
+ 'Operation listPermissions must require resourceType',
+ );
+ const policyParameters = contract
+ .requireOperation('listTablePolicies')
+ .parameters.map((parameter) =>
+ parameter.$ref ? parameter.$ref.split('/').pop() : parameter.name,
+ );
+ ['PolicyTypeQuery', 'PrincipalQuery', 'PolicyColumnQuery', 'PageToken',
'MaxResults'].forEach(
+ (name) =>
+ contract.checkSpec(
+ policyParameters.includes(name),
+ `Operation listTablePolicies is missing query parameter: ${name}`,
+ ),
+ );
+ contract.checkSpec(
+ !policyParameters.includes('PolicyNameQuery'),
+ 'Principal-scoped policies must not expose a policy-name filter',
+ );
+ const tablePoliciesPath =
+
contract.spec.paths['/v1/{prefix}/databases/{database}/tables/{table}/policies'];
+ const dropTablePolicyPath =
+
contract.spec.paths['/v1/{prefix}/databases/{database}/tables/{table}/policies/drop'];
+ contract.checkSpec(
+ tablePoliciesPath.get &&
+ tablePoliciesPath.post &&
+ !tablePoliciesPath.put &&
+ !tablePoliciesPath.delete,
+ 'The table policy collection must expose only list and strict creation',
+ );
+ contract.checkSpec(
+ dropTablePolicyPath.post && !dropTablePolicyPath.delete,
+ 'Policy deletion must use a body-bearing POST action instead of DELETE',
+ );
+
+ contract.checkSpec(
+ contract.operations.size === operationIds.length,
+ `The management contract must define exactly ${operationIds.length}
operations`,
+ );
+ resourcePaths.forEach((resourcePath) =>
+ contract.checkSpec(
+ contract.spec.paths[resourcePath].parameters.some(
+ (parameter) => parameter.$ref === '#/components/parameters/Prefix',
+ ),
+ `Path ${resourcePath} must reuse components.parameters.Prefix`,
+ ),
+ );
+ return contract.operations.size;
+}
+
+const catalogOperationCount = validateCatalogOpenApi();
+const managementOperationCount = validateManagementOpenApi();
+console.log(`Validated REST Catalog OpenAPI contract with
${catalogOperationCount} operations.`);
+console.log(
+ `Validated REST Management OpenAPI contract with ${managementOperationCount}
operations.`,
+);
diff --git a/docs/sidebars.js b/docs/sidebars.js
index cb70d4ad19..cd2b6b2d95 100644
--- a/docs/sidebars.js
+++ b/docs/sidebars.js
@@ -31,7 +31,8 @@ const sidebars = {
"concepts/rest/dlf",
"concepts/rest/tables",
"concepts/rest/pvfs",
- "concepts/rest/rest-api"
+ "concepts/rest/rest-api",
+ "concepts/rest/management-api"
]
},
{
diff --git a/docs/static/rest-management-open-api.yaml
b/docs/static/rest-management-open-api.yaml
new file mode 100644
index 0000000000..4da5a572b5
--- /dev/null
+++ b/docs/static/rest-management-open-api.yaml
@@ -0,0 +1,852 @@
+# 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.
+
+openapi: 3.1.1
+info:
+ title: Paimon REST Management API
+ version: "1.0"
+ description: |
+ Experimental control-plane extension for object and column permissions,
table row filters, and
+ table column masks in one Paimon REST Catalog prefix. A data policy
restricts an
+ already-authorized read;
+ it never grants SELECT by itself. Each policy is attached to one
principal: a principal has at
+ most one row filter per table and at most one mask per table column.
Policy creation is accepted
+ only when the target table exists, has `query-auth.enabled=true`, and the
referenced principal,
+ serialized Paimon predicate or transform, and columns are valid.
+ Authentication follows the REST Catalog deployment configuration and is
not fixed by this
+ extension.
+ Principal lifecycle, audit, and persistence remain server
responsibilities. This contract may
+ evolve incompatibly while experimental.
+ license:
+ name: Apache 2.0
+ url: https://www.apache.org/licenses/LICENSE-2.0.html
+servers:
+ - url: http://localhost:8080
+paths:
+ /v1/{prefix}/permissions:
+ parameters:
+ - $ref: '#/components/parameters/Prefix'
+ get:
+ tags: [permission]
+ summary: List direct permission assignments on a resource or scope
+ operationId: listPermissions
+ description: |
+ Returns direct assignments attached to the requested exact resource or
explicit descendant
+ scope. This operation does not synthesize assignments effective
through CATALOG_ALL or
+ DATABASE_ALL. Expired assignments may remain visible until server
cleanup, but must not
+ authorize access. Following the catalog pagination contract, an empty
page terminates
+ pagination and therefore must not carry a continuation token.
+ parameters:
+ - $ref: '#/components/parameters/ResourceTypeQuery'
+ - $ref: '#/components/parameters/DatabaseQuery'
+ - $ref: '#/components/parameters/TableQuery'
+ - $ref: '#/components/parameters/FunctionQuery'
+ - $ref: '#/components/parameters/ViewQuery'
+ - $ref: '#/components/parameters/PrincipalQuery'
+ - $ref: '#/components/parameters/AccessQuery'
+ - $ref: '#/components/parameters/PageToken'
+ - $ref: '#/components/parameters/MaxResults'
+ responses:
+ '200':
+ description: Permission assignments in stable pagination order.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ListPermissionsResponse'
+ '400':
+ $ref: '#/components/responses/BadRequest'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ '500':
+ $ref: '#/components/responses/ServerError'
+ '503':
+ $ref: '#/components/responses/ServiceUnavailable'
+ /v1/{prefix}/permissions/grant:
+ parameters:
+ - $ref: '#/components/parameters/Prefix'
+ post:
+ tags: [permission]
+ summary: Grant or replace a permission assignment
+ operationId: grantPermission
+ description: |
+ Creates an assignment or replaces the assignment with the same
`resource`, `access`, and
+ `principal`. For a `COLUMN` assignment this replaces the whole
included or excluded column
+ range. The referenced resource, principal, and columns must exist.
Servers bind assignments
+ to the resource lifecycle described by `PermissionResource`.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/GrantPermissionRequest'
+ responses:
+ '200':
+ description: Permission assignment created or replaced.
+ '400':
+ $ref: '#/components/responses/BadRequest'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '409':
+ $ref: '#/components/responses/Conflict'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ '500':
+ $ref: '#/components/responses/ServerError'
+ '503':
+ $ref: '#/components/responses/ServiceUnavailable'
+ /v1/{prefix}/permissions/revoke:
+ parameters:
+ - $ref: '#/components/parameters/Prefix'
+ post:
+ tags: [permission]
+ summary: Idempotently revoke a permission assignment
+ operationId: revokePermission
+ description: |
+ Makes the assignment absent. A 404 identifies a missing resource or
principal, not an
+ already-absent assignment.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RevokePermissionRequest'
+ responses:
+ '200':
+ description: Permission assignment is absent.
+ '400':
+ $ref: '#/components/responses/BadRequest'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ '500':
+ $ref: '#/components/responses/ServerError'
+ '503':
+ $ref: '#/components/responses/ServiceUnavailable'
+
+ /v1/{prefix}/databases/{database}/tables/{table}/policies:
+ parameters:
+ - $ref: '#/components/parameters/Prefix'
+ - $ref: '#/components/parameters/Database'
+ - $ref: '#/components/parameters/Table'
+ get:
+ tags: [policy]
+ summary: List policies attached directly to a table
+ operationId: listTablePolicies
+ description: |
+ Policies are attached directly to the requested table. Following the
catalog pagination
+ contract, an empty page terminates pagination and therefore must not
carry a continuation
+ token.
+ parameters:
+ - $ref: '#/components/parameters/PolicyTypeQuery'
+ - $ref: '#/components/parameters/PrincipalQuery'
+ - $ref: '#/components/parameters/PolicyColumnQuery'
+ - $ref: '#/components/parameters/PageToken'
+ - $ref: '#/components/parameters/MaxResults'
+ responses:
+ '200':
+ description: Table policies in stable pagination order.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ListPoliciesResponse'
+ '400':
+ $ref: '#/components/responses/BadRequest'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ '500':
+ $ref: '#/components/responses/ServerError'
+ '503':
+ $ref: '#/components/responses/ServiceUnavailable'
+ post:
+ tags: [policy]
+ summary: Create a table policy
+ operationId: createTablePolicy
+ description: |
+ Fails with 409 when the same principal already has a row filter or a
mask on the same
+ column, or when the table has not enabled query authorization. The
principal, policy
+ predicate or transform, protected column, and referenced fields are
validated before
+ persistence.
+ requestBody:
+ $ref: '#/components/requestBodies/PolicyRequest'
+ responses:
+ '200':
+ description: Policy created.
+ '400':
+ $ref: '#/components/responses/BadRequest'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '409':
+ $ref: '#/components/responses/Conflict'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ '500':
+ $ref: '#/components/responses/ServerError'
+ '503':
+ $ref: '#/components/responses/ServiceUnavailable'
+ /v1/{prefix}/databases/{database}/tables/{table}/policies/drop:
+ parameters:
+ - $ref: '#/components/parameters/Prefix'
+ - $ref: '#/components/parameters/Database'
+ - $ref: '#/components/parameters/Table'
+ post:
+ tags: [policy]
+ summary: Drop a table policy
+ operationId: dropTablePolicy
+ description: Returns 404 when the policy is absent; clients may expose
idempotency explicitly.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DropPolicyRequest'
+ responses:
+ '200':
+ description: Policy dropped.
+ '400':
+ $ref: '#/components/responses/BadRequest'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ '500':
+ $ref: '#/components/responses/ServerError'
+ '503':
+ $ref: '#/components/responses/ServiceUnavailable'
+
+components:
+ securitySchemes:
+ BearerAuth:
+ type: http
+ scheme: bearer
+ parameters:
+ Prefix:
+ name: prefix
+ in: path
+ required: true
+ description: Opaque REST catalog prefix returned by the catalog config
endpoint.
+ schema:
+ type: string
+ minLength: 1
+ Database:
+ name: database
+ in: path
+ required: true
+ schema:
+ type: string
+ minLength: 1
+ Table:
+ name: table
+ in: path
+ required: true
+ schema:
+ type: string
+ minLength: 1
+ ResourceTypeQuery:
+ name: resourceType
+ in: query
+ required: true
+ schema:
+ $ref: '#/components/schemas/ResourceType'
+ DatabaseQuery:
+ name: database
+ in: query
+ schema:
+ type: string
+ minLength: 1
+ TableQuery:
+ name: table
+ in: query
+ schema:
+ type: string
+ minLength: 1
+ FunctionQuery:
+ name: function
+ in: query
+ schema:
+ type: string
+ minLength: 1
+ ViewQuery:
+ name: view
+ in: query
+ schema:
+ type: string
+ minLength: 1
+ PrincipalQuery:
+ name: principal
+ in: query
+ description: Exact opaque principal identifier.
+ schema:
+ $ref: '#/components/schemas/Principal'
+ AccessQuery:
+ name: access
+ in: query
+ schema:
+ $ref: '#/components/schemas/PermissionAccess'
+ PolicyColumnQuery:
+ name: column
+ in: query
+ description: Valid only together with `type=COLUMN_MASKING`.
+ schema:
+ type: string
+ minLength: 1
+ PolicyTypeQuery:
+ name: type
+ in: query
+ schema:
+ $ref: '#/components/schemas/PolicyType'
+ PageToken:
+ name: pageToken
+ in: query
+ description: Opaque continuation token returned by the preceding
response.
+ schema:
+ type: string
+ MaxResults:
+ name: maxResults
+ in: query
+ schema:
+ type: integer
+ minimum: 1
+ maximum: 1000
+
+ requestBodies:
+ PolicyRequest:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PolicyRequest'
+
+ responses:
+ BadRequest:
+ description: Invalid request shape, resource identity, predicate,
transform, or column.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ Unauthorized:
+ description: Missing or invalid authentication.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ Forbidden:
+ description: Caller cannot manage the target resource.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ NotFound:
+ description: Referenced resource, principal, or policy does not exist.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ Conflict:
+ description: Existing policy conflict or query authorization is not
enabled.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ TooManyRequests:
+ description: Request rate limit exceeded; retry only according to server
guidance.
+ headers:
+ Retry-After:
+ $ref: '#/components/headers/RetryAfter'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ ServerError:
+ description: Unexpected server error.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ ServiceUnavailable:
+ description: Management service is temporarily unavailable.
+ headers:
+ Retry-After:
+ $ref: '#/components/headers/RetryAfter'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+
+ headers:
+ RetryAfter:
+ description: Delay before retrying, as HTTP delta-seconds or an HTTP
date.
+ schema:
+ type: string
+
+ schemas:
+ ResourceType:
+ type: string
+ enum: [CATALOG, CATALOG_ALL, DATABASE, DATABASE_ALL, TABLE, COLUMN,
FUNCTION, VIEW]
+ PermissionAccess:
+ description: |
+ Canonical data access value. The REST wire format uses upper case and
creation accesses
+ follow their persisted names without underscores. UPDATE covers table
writes, including
+ inserts and deletes. SELECT also covers function use. GRANT allows
granting and revoking
+ assignments on the resource. The server validates applicability to the
resource type.
+ type: string
+ maxLength: 32
+ enum:
+ - ALL
+ - CREATEDATABASE
+ - DESCRIBE
+ - ALTER
+ - DROP
+ - CREATETABLE
+ - CREATEFUNCTION
+ - CREATEVIEW
+ - LIST
+ - SELECT
+ - UPDATE
+ - GRANT
+ PolicyType:
+ type: string
+ enum: [ROW_FILTER, COLUMN_MASKING]
+ Principal:
+ type: string
+ minLength: 1
+ maxLength: 128
+ description: |
+ Opaque, canonical, globally unique identifier in the server principal
namespace. Type and
+ membership resolution are server responsibilities.
+ PermissionResource:
+ description: |
+ Exact catalog-scoped wire locator or explicit descendant scope; fields
not selected by type
+ are invalid. CATALOG_ALL covers descendants of the configured catalog,
while DATABASE_ALL
+ covers descendants of the named database. Servers must bind stored
direct assignments to a
+ stable internal resource identity. Renaming a database, table,
function, or view retains its
+ direct assignments and responses use the new locator. Dropping a
resource removes its direct
+ assignments, and recreating the same locator must not inherit them.
+ oneOf:
+ - $ref: '#/components/schemas/CatalogResource'
+ - $ref: '#/components/schemas/CatalogAllResource'
+ - $ref: '#/components/schemas/DatabaseResource'
+ - $ref: '#/components/schemas/DatabaseAllResource'
+ - $ref: '#/components/schemas/TableResource'
+ - $ref: '#/components/schemas/ColumnResource'
+ - $ref: '#/components/schemas/FunctionResource'
+ - $ref: '#/components/schemas/ViewResource'
+ discriminator:
+ propertyName: type
+ mapping:
+ CATALOG: '#/components/schemas/CatalogResource'
+ CATALOG_ALL: '#/components/schemas/CatalogAllResource'
+ DATABASE: '#/components/schemas/DatabaseResource'
+ DATABASE_ALL: '#/components/schemas/DatabaseAllResource'
+ TABLE: '#/components/schemas/TableResource'
+ COLUMN: '#/components/schemas/ColumnResource'
+ FUNCTION: '#/components/schemas/FunctionResource'
+ VIEW: '#/components/schemas/ViewResource'
+ CatalogResource:
+ type: object
+ additionalProperties: false
+ required: [type]
+ properties:
+ type:
+ type: string
+ const: CATALOG
+ CatalogAllResource:
+ type: object
+ additionalProperties: false
+ description: Explicit scope covering database, table, view, and function
descendants.
+ required: [type]
+ properties:
+ type:
+ type: string
+ const: CATALOG_ALL
+ DatabaseResource:
+ type: object
+ additionalProperties: false
+ required: [type, database]
+ properties:
+ type:
+ type: string
+ const: DATABASE
+ database:
+ type: string
+ minLength: 1
+ DatabaseAllResource:
+ type: object
+ additionalProperties: false
+ description: Explicit scope covering table, view, and function
descendants of one database.
+ required: [type, database]
+ properties:
+ type:
+ type: string
+ const: DATABASE_ALL
+ database:
+ type: string
+ minLength: 1
+ TableResource:
+ type: object
+ additionalProperties: false
+ required: [type, database, table]
+ properties:
+ type:
+ type: string
+ const: TABLE
+ database:
+ type: string
+ minLength: 1
+ table:
+ type: string
+ minLength: 1
+ ColumnResource:
+ type: object
+ additionalProperties: false
+ description: |
+ Column permission target. The resource identity is the containing
table; the mutable column
+ range is carried by `PermissionAssignment.columns` and is not part of
the identity.
+ required: [type, database, table]
+ properties:
+ type:
+ type: string
+ const: COLUMN
+ database:
+ type: string
+ minLength: 1
+ table:
+ type: string
+ minLength: 1
+ FunctionResource:
+ type: object
+ additionalProperties: false
+ required: [type, database, function]
+ properties:
+ type:
+ type: string
+ const: FUNCTION
+ database:
+ type: string
+ minLength: 1
+ function:
+ type: string
+ minLength: 1
+ ViewResource:
+ type: object
+ additionalProperties: false
+ required: [type, database, view]
+ properties:
+ type:
+ type: string
+ const: VIEW
+ database:
+ type: string
+ minLength: 1
+ view:
+ type: string
+ minLength: 1
+ ExpireTime:
+ type: string
+ format: date-time
+ pattern: '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$'
+ description: |
+ Exclusive authorization upper bound evaluated using the server clock.
At
+ `now >= expireTime` the assignment must not authorize access. An
expired record may remain
+ listable until cleanup. The value must use UTC `Z` and have at most
millisecond precision.
+ PermissionAssignment:
+ type: object
+ additionalProperties: false
+ description: |
+ Direct assignment identity is `resource`, `access`, and `principal`.
CATALOG_ALL and
+ DATABASE_ALL are explicit descendant scopes rather than computed
effective assignments.
+ `columns` is required only for a COLUMN assignment and is replaced as
one value when the
+ same identity is granted again.
+ required: [resource, access, principal]
+ properties:
+ resource:
+ $ref: '#/components/schemas/PermissionResource'
+ access:
+ $ref: '#/components/schemas/PermissionAccess'
+ principal:
+ $ref: '#/components/schemas/Principal'
+ columns:
+ $ref: '#/components/schemas/PermissionColumns'
+ expireTime:
+ $ref: '#/components/schemas/ExpireTime'
+ allOf:
+ - $ref: '#/components/schemas/ColumnAssignmentConstraint'
+ GrantPermissionRequest:
+ type: object
+ additionalProperties: false
+ required: [resource, access, principal]
+ properties:
+ resource:
+ $ref: '#/components/schemas/PermissionResource'
+ access:
+ $ref: '#/components/schemas/PermissionAccess'
+ principal:
+ $ref: '#/components/schemas/Principal'
+ columns:
+ $ref: '#/components/schemas/PermissionColumns'
+ expireTime:
+ $ref: '#/components/schemas/ExpireTime'
+ allOf:
+ - $ref: '#/components/schemas/ColumnAssignmentConstraint'
+ PermissionColumns:
+ type: object
+ additionalProperties: false
+ description: |
+ Exactly one non-empty list of top-level table column names.
`columnNames` is an allowlist;
+ `excludedColumnNames` grants every current table column except the
listed denylist. Column
+ names must exist when granted. An allowlist denies columns added
later, while a denylist
+ allows columns added later. All applicable column ranges are
intersected, and selecting any
+ column outside the effective range fails the query. The target table
must enforce query
+ authorization before the grant becomes visible.
+ properties:
+ columnNames:
+ type: array
+ minItems: 1
+ uniqueItems: true
+ items:
+ type: string
+ minLength: 1
+ excludedColumnNames:
+ type: array
+ minItems: 1
+ uniqueItems: true
+ items:
+ type: string
+ minLength: 1
+ oneOf:
+ - required: [columnNames]
+ - required: [excludedColumnNames]
+ ColumnAssignmentConstraint:
+ if:
+ properties:
+ resource:
+ type: object
+ required: [type]
+ properties:
+ type:
+ type: string
+ const: COLUMN
+ required: [resource]
+ then:
+ required: [columns]
+ else:
+ not:
+ required: [columns]
+ RevokePermissionRequest:
+ type: object
+ additionalProperties: false
+ required: [resource, access, principal]
+ properties:
+ resource:
+ $ref: '#/components/schemas/PermissionResource'
+ access:
+ $ref: '#/components/schemas/PermissionAccess'
+ principal:
+ $ref: '#/components/schemas/Principal'
+ ListPermissionsResponse:
+ type: object
+ additionalProperties: false
+ required: [permissions]
+ properties:
+ permissions:
+ type: array
+ items:
+ $ref: '#/components/schemas/PermissionAssignment'
+ nextPageToken:
+ type: string
+
+ RowFilter:
+ type: object
+ additionalProperties: false
+ required: [predicate]
+ properties:
+ predicate:
+ type: string
+ minLength: 1
+ x-maxUtf8Bytes: 61440
+ contentMediaType: application/json
+ description: |
+ JSON serialization of one Paimon `Predicate`, using the same
representation as one
+ entry in `AuthTableQueryResponse.filter`. The UTF-8 representation
must not exceed
+ 60 KiB. The server must deserialize, validate against the target
table schema, and
+ canonicalize it when the policy is created.
+ ColumnMask:
+ type: object
+ additionalProperties: false
+ required: [onColumn, transform]
+ properties:
+ onColumn:
+ type: string
+ minLength: 1
+ transform:
+ type: string
+ minLength: 1
+ x-maxUtf8Bytes: 61440
+ contentMediaType: application/json
+ description: |
+ JSON serialization of one Paimon `Transform`, using the same
representation as the
+ value for `onColumn` in `AuthTableQueryResponse.columnMasking`.
The UTF-8
+ representation must not exceed 60 KiB. The server must deserialize
it, validate all
+ field references and the output type against the target table
schema, and canonicalize
+ it when the policy is created.
+ RowFilterPolicyRequest:
+ type: object
+ additionalProperties: false
+ required: [rowFilter, principal]
+ properties:
+ rowFilter:
+ $ref: '#/components/schemas/RowFilter'
+ principal:
+ $ref: '#/components/schemas/Principal'
+ ColumnMaskPolicyRequest:
+ type: object
+ additionalProperties: false
+ required: [columnMask, principal]
+ properties:
+ columnMask:
+ $ref: '#/components/schemas/ColumnMask'
+ principal:
+ $ref: '#/components/schemas/Principal'
+ PolicyRequest:
+ description: Exactly one typed policy definition for one principal is
allowed.
+ oneOf:
+ - $ref: '#/components/schemas/RowFilterPolicyRequest'
+ - $ref: '#/components/schemas/ColumnMaskPolicyRequest'
+ RowFilterPolicyIdentity:
+ type: object
+ additionalProperties: false
+ required: [type, principal]
+ properties:
+ type:
+ type: string
+ const: ROW_FILTER
+ principal:
+ $ref: '#/components/schemas/Principal'
+ ColumnMaskPolicyIdentity:
+ type: object
+ additionalProperties: false
+ required: [type, principal, column]
+ properties:
+ type:
+ type: string
+ const: COLUMN_MASKING
+ principal:
+ $ref: '#/components/schemas/Principal'
+ column:
+ type: string
+ minLength: 1
+ DropPolicyRequest:
+ description: Exact principal policy identity on the table named by the
request path.
+ oneOf:
+ - $ref: '#/components/schemas/RowFilterPolicyIdentity'
+ - $ref: '#/components/schemas/ColumnMaskPolicyIdentity'
+ TablePolicyResource:
+ type: object
+ additionalProperties: false
+ required: [type, database, table]
+ properties:
+ type:
+ type: string
+ const: TABLE
+ database:
+ type: string
+ minLength: 1
+ table:
+ type: string
+ minLength: 1
+ RowFilterDataPolicy:
+ type: object
+ additionalProperties: false
+ required: [resource, rowFilter, principal]
+ properties:
+ resource:
+ $ref: '#/components/schemas/TablePolicyResource'
+ rowFilter:
+ $ref: '#/components/schemas/RowFilter'
+ principal:
+ $ref: '#/components/schemas/Principal'
+ ColumnMaskDataPolicy:
+ type: object
+ additionalProperties: false
+ required: [resource, columnMask, principal]
+ properties:
+ resource:
+ $ref: '#/components/schemas/TablePolicyResource'
+ columnMask:
+ $ref: '#/components/schemas/ColumnMask'
+ principal:
+ $ref: '#/components/schemas/Principal'
+ DataPolicy:
+ description: |
+ A policy applies when its principal is effective for the caller. All
applicable row filters
+ must be combined with logical AND. More than one applicable column
mask for the same column
+ must fail closed. An invalid, unsupported, or schema-incompatible
predicate or transform
+ must also fail closed rather than omit a restriction.
+ oneOf:
+ - $ref: '#/components/schemas/RowFilterDataPolicy'
+ - $ref: '#/components/schemas/ColumnMaskDataPolicy'
+ ListPoliciesResponse:
+ type: object
+ additionalProperties: false
+ required: [policies]
+ properties:
+ policies:
+ type: array
+ items:
+ $ref: '#/components/schemas/DataPolicy'
+ nextPageToken:
+ type: string
+ ErrorResponse:
+ type: object
+ additionalProperties: true
+ required: [message, code]
+ properties:
+ resourceType:
+ type: string
+ resourceName:
+ type: string
+ message:
+ type: string
+ code:
+ type: integer