From 890ee207e48c0f833fbbd742ac767d33ac61e317 Mon Sep 17 00:00:00 2001 From: JingsongLi Date: Wed, 26 Aug 2026 15:53:17 +0800 Subject: [PATCH 1/3] [docs] Add REST management OpenAPI --- 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 | 861 ++++++++++++++++++---- docs/sidebars.js | 3 +- docs/static/rest-management-open-api.yaml | 842 +++++++++++++++++++++ 6 files changed, 1807 insertions(+), 136 deletions(-) create mode 100644 docs/docs/concepts/rest/management-api.md create mode 100644 docs/static/rest-management-open-api.yaml diff --git a/docs/README.md b/docs/README.md index 67d65ae18e9f..5e7ba5586c36 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 bd8ada4b6712..57a328383167 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 000000000000..8c4fdc56e6bb --- /dev/null +++ b/docs/docs/concepts/rest/management-api.md @@ -0,0 +1,232 @@ +--- +title: "REST Management API" +hide_table_of_contents: true +--- + + + +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. + +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 62e3598cf555..e0ce10d8d8ac 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 checkSpec(condition, message) { + check(condition, `${fileName}: ${message}`); + } -function visit(value) { - if (Array.isArray(value)) { - value.forEach(visit); - 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); } - if (!value || typeof value !== 'object') { - return; + + 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); } - check(!Object.prototype.hasOwnProperty.call(value, 'nullable'), 'OpenAPI 3.1 schemas must not use nullable'); - if (typeof value.$ref === 'string') { - resolveLocalRef(value.$ref); + + function schema(name) { + const value = spec.components && spec.components.schemas && spec.components.schemas[name]; + checkSpec(value, `Missing OpenAPI schema: ${name}`); + return value; } - 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 requireProperties(schemaName, names) { + const properties = schema(schemaName).properties || {}; + names.forEach((name) => { + checkSpec(properties[name], `Schema ${schemaName} is missing property: ${name}`); + }); + return properties; + } -function requireProperties(schemaName, names) { - const properties = schema(schemaName).properties || {}; - names.forEach((name) => { - check(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,582 @@ 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(', ')}`, + ); +} + +function validateCatalogOpenApi() { + const contract = validateCommon('rest-catalog-open-api.yaml'); + [ + 'getConfig', + 'createDatabase', + 'getDatabase', + 'alterDatabase', + 'dropDatabase', + 'createTable', + 'getTable', + 'alterTable', + 'dropTable', + ].forEach(contract.requireOperation); -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); - } + 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']); + + 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', + ); -console.log(`Validated REST OpenAPI contract with ${operationIds.size} operations.`); + const assignmentExpiry = contract.requireProperties('PermissionAssignment', ['expireTime']) + .expireTime.description.toLowerCase(); + const grantExpiry = contract.requireProperties('GrantPermissionRequest', ['expireTime']) + .expireTime.description.toLowerCase(); + [assignmentExpiry, grantExpiry].forEach((description) => { + contract.checkSpec( + description.includes('exclusive') && + description.includes('server clock') && + description.includes('millisecond') && + description.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}`, + ), + ); + + 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 cb70d4ad19fd..cd2b6b2d95f3 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 000000000000..3c55cf93f726 --- /dev/null +++ b/docs/static/rest-management-open-api.yaml @@ -0,0 +1,842 @@ +# 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: + const: CATALOG + CatalogAllResource: + type: object + additionalProperties: false + description: Explicit scope covering database, table, view, and function descendants. + required: [type] + properties: + type: + const: CATALOG_ALL + DatabaseResource: + type: object + additionalProperties: false + required: [type, database] + properties: + type: + 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: + const: DATABASE_ALL + database: + type: string + minLength: 1 + TableResource: + type: object + additionalProperties: false + required: [type, database, table] + properties: + type: + 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: + const: COLUMN + database: + type: string + minLength: 1 + table: + type: string + minLength: 1 + FunctionResource: + type: object + additionalProperties: false + required: [type, database, function] + properties: + type: + const: FUNCTION + database: + type: string + minLength: 1 + function: + type: string + minLength: 1 + ViewResource: + type: object + additionalProperties: false + required: [type, database, view] + properties: + type: + const: VIEW + database: + type: string + minLength: 1 + view: + type: string + minLength: 1 + 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: + type: string + format: date-time + description: | + Exclusive authorization upper bound evaluated using the server clock. At + `now >= expireTime` this assignment must not authorize access. An expired record may + remain listable until cleanup. The value must have at most millisecond precision. + 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: + type: string + format: date-time + description: | + Exclusive authorization upper bound evaluated using the server clock. At + `now >= expireTime` the assignment must not authorize access. The value must have at + most millisecond precision. + 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: + 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: + const: ROW_FILTER + principal: + $ref: '#/components/schemas/Principal' + ColumnMaskPolicyIdentity: + type: object + additionalProperties: false + required: [type, principal, column] + properties: + type: + 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: + 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 From 75e1a415edc9caebf14cb176db850f498ace527e Mon Sep 17 00:00:00 2001 From: JingsongLi Date: Wed, 26 Aug 2026 16:37:07 +0800 Subject: [PATCH 2/3] [docs] Constrain permission expiry timestamps --- docs/docs/concepts/rest/management-api.md | 2 +- docs/scripts/validate-rest-openapi.js | 37 +++++++++++++++++------ docs/static/rest-management-open-api.yaml | 22 ++++++-------- 3 files changed, 38 insertions(+), 23 deletions(-) diff --git a/docs/docs/concepts/rest/management-api.md b/docs/docs/concepts/rest/management-api.md index 8c4fdc56e6bb..95892fa8a211 100644 --- a/docs/docs/concepts/rest/management-api.md +++ b/docs/docs/concepts/rest/management-api.md @@ -178,7 +178,7 @@ 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. +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 diff --git a/docs/scripts/validate-rest-openapi.js b/docs/scripts/validate-rest-openapi.js index e0ce10d8d8ac..73ef2260ba58 100644 --- a/docs/scripts/validate-rest-openapi.js +++ b/docs/scripts/validate-rest-openapi.js @@ -602,19 +602,36 @@ function validateManagementOpenApi() { 'RetryAfter must allow HTTP delta-seconds or an HTTP date as a string', ); - const assignmentExpiry = contract.requireProperties('PermissionAssignment', ['expireTime']) - .expireTime.description.toLowerCase(); - const grantExpiry = contract.requireProperties('GrantPermissionRequest', ['expireTime']) - .expireTime.description.toLowerCase(); - [assignmentExpiry, grantExpiry].forEach((description) => { + 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( - description.includes('exclusive') && - description.includes('server clock') && - description.includes('millisecond') && - description.includes('must not authorize'), - 'expireTime must define exclusive millisecond server-clock authorization semantics', + 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( diff --git a/docs/static/rest-management-open-api.yaml b/docs/static/rest-management-open-api.yaml index 3c55cf93f726..155aa2fb7317 100644 --- a/docs/static/rest-management-open-api.yaml +++ b/docs/static/rest-management-open-api.yaml @@ -574,6 +574,14 @@ components: 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 @@ -593,12 +601,7 @@ components: columns: $ref: '#/components/schemas/PermissionColumns' expireTime: - type: string - format: date-time - description: | - Exclusive authorization upper bound evaluated using the server clock. At - `now >= expireTime` this assignment must not authorize access. An expired record may - remain listable until cleanup. The value must have at most millisecond precision. + $ref: '#/components/schemas/ExpireTime' allOf: - $ref: '#/components/schemas/ColumnAssignmentConstraint' GrantPermissionRequest: @@ -615,12 +618,7 @@ components: columns: $ref: '#/components/schemas/PermissionColumns' expireTime: - type: string - format: date-time - description: | - Exclusive authorization upper bound evaluated using the server clock. At - `now >= expireTime` the assignment must not authorize access. The value must have at - most millisecond precision. + $ref: '#/components/schemas/ExpireTime' allOf: - $ref: '#/components/schemas/ColumnAssignmentConstraint' PermissionColumns: From 904e19056a84ab9133293406ae9de59787b6e488 Mon Sep 17 00:00:00 2001 From: JingsongLi Date: Wed, 26 Aug 2026 16:41:13 +0800 Subject: [PATCH 3/3] [docs] Type management schema discriminators --- docs/scripts/validate-rest-openapi.js | 19 +++++++++++++++++++ docs/static/rest-management-open-api.yaml | 12 ++++++++++++ 2 files changed, 31 insertions(+) diff --git a/docs/scripts/validate-rest-openapi.js b/docs/scripts/validate-rest-openapi.js index 73ef2260ba58..c4846178fb52 100644 --- a/docs/scripts/validate-rest-openapi.js +++ b/docs/scripts/validate-rest-openapi.js @@ -469,6 +469,25 @@ function validateManagementOpenApi() { '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 = [ diff --git a/docs/static/rest-management-open-api.yaml b/docs/static/rest-management-open-api.yaml index 155aa2fb7317..4da5a572b5b5 100644 --- a/docs/static/rest-management-open-api.yaml +++ b/docs/static/rest-management-open-api.yaml @@ -489,6 +489,7 @@ components: required: [type] properties: type: + type: string const: CATALOG CatalogAllResource: type: object @@ -497,6 +498,7 @@ components: required: [type] properties: type: + type: string const: CATALOG_ALL DatabaseResource: type: object @@ -504,6 +506,7 @@ components: required: [type, database] properties: type: + type: string const: DATABASE database: type: string @@ -515,6 +518,7 @@ components: required: [type, database] properties: type: + type: string const: DATABASE_ALL database: type: string @@ -525,6 +529,7 @@ components: required: [type, database, table] properties: type: + type: string const: TABLE database: type: string @@ -541,6 +546,7 @@ components: required: [type, database, table] properties: type: + type: string const: COLUMN database: type: string @@ -554,6 +560,7 @@ components: required: [type, database, function] properties: type: + type: string const: FUNCTION database: type: string @@ -567,6 +574,7 @@ components: required: [type, database, view] properties: type: + type: string const: VIEW database: type: string @@ -657,6 +665,7 @@ components: required: [type] properties: type: + type: string const: COLUMN required: [resource] then: @@ -750,6 +759,7 @@ components: required: [type, principal] properties: type: + type: string const: ROW_FILTER principal: $ref: '#/components/schemas/Principal' @@ -759,6 +769,7 @@ components: required: [type, principal, column] properties: type: + type: string const: COLUMN_MASKING principal: $ref: '#/components/schemas/Principal' @@ -776,6 +787,7 @@ components: required: [type, database, table] properties: type: + type: string const: TABLE database: type: string