diff --git a/changes/12359.feature.md b/changes/12359.feature.md new file mode 100644 index 00000000000..8299e0a50f2 --- /dev/null +++ b/changes/12359.feature.md @@ -0,0 +1 @@ +Add the app_config read service: resolve a user's merged AppConfig for one or many config names by rank-ordering the visible public / domain / user fragments and deep-merging them. diff --git a/changes/12377.feature.md b/changes/12377.feature.md new file mode 100644 index 00000000000..a815efd834c --- /dev/null +++ b/changes/12377.feature.md @@ -0,0 +1 @@ +Add the AppConfig REST v2 API: admin CRUD and paginated search of raw config fragments, principal-scoped fragment search, the merged `(user, config_name)` resolve, and an anonymous public-only merged read (BEP-1052). diff --git a/changes/12755.feature.md b/changes/12755.feature.md new file mode 100644 index 00000000000..791473e191f --- /dev/null +++ b/changes/12755.feature.md @@ -0,0 +1 @@ +Add `read_access` / `write_access` access-level columns to the AppConfig allow list, decoupling read-enablement from write-authorization so a config layer's read and write audiences are explicit rather than implied by the allow-list row's existence (BEP-1052). diff --git a/changes/12756.feature.md b/changes/12756.feature.md new file mode 100644 index 00000000000..11b6525ae1c --- /dev/null +++ b/changes/12756.feature.md @@ -0,0 +1 @@ +Expose the AppConfig allow-list `read_access` / `write_access` tiers on the v2 API (REST, GraphQL, SDK, CLI), so admins can set and re-policy who may read / write each config layer (BEP-1052). diff --git a/changes/12759.feature.md b/changes/12759.feature.md new file mode 100644 index 00000000000..1cfcb99c30f --- /dev/null +++ b/changes/12759.feature.md @@ -0,0 +1 @@ +Authorize AppConfig fragment writes against the allow-list `write_access` tier so a user can manage their own user-scope fragment while public/domain layers stay admin-only; fragment write endpoints move from superadmin-only to authenticated with per-write tier checks (BEP-1052). diff --git a/changes/12764.feature.md b/changes/12764.feature.md new file mode 100644 index 00000000000..fb0a07ede42 --- /dev/null +++ b/changes/12764.feature.md @@ -0,0 +1 @@ +Authorize AppConfig merged-config reads against the allow-list `read_access` tier so a config layer contributes to a caller's read only when the caller may read it; anonymous public reads expose only public-readable layers (BEP-1052). diff --git a/docs/manager/graphql-reference/supergraph.graphql b/docs/manager/graphql-reference/supergraph.graphql index 7f118f8448f..69d0ffd397b 100644 --- a/docs/manager/graphql-reference/supergraph.graphql +++ b/docs/manager/graphql-reference/supergraph.graphql @@ -887,6 +887,15 @@ type AllowedResourceGroupsPayload items: [String!]! } +enum AppConfigAccessLevel + @join__type(graph: STRAWBERRY) +{ + PUBLIC @join__enumValue(graph: STRAWBERRY) + AUTHENTICATED @join__enumValue(graph: STRAWBERRY) + OWNER @join__enumValue(graph: STRAWBERRY) + ADMIN @join__enumValue(graph: STRAWBERRY) +} + """ Added in 26.7.0. Permission to write config fragments for one config name at one scope type. """ @@ -908,6 +917,16 @@ type AppConfigAllowList implements Node """ rank: Int! + """ + Added in 26.8.0. Minimum principal tier allowed to read the fragments under this entry. + """ + readAccess: AppConfigAccessLevel! + + """ + Added in 26.8.0. Minimum principal tier allowed to write the fragments under this entry. + """ + writeAccess: AppConfigAccessLevel! + """Creation timestamp (UTC).""" createdAt: DateTime! @@ -3524,6 +3543,16 @@ input CreateAppConfigAllowListInput Added in 26.8.0. Merge rank applied to fragments under this entry (low to high; higher wins). Defaults to the scope type's default rank (public=100, domain=200, user=300). """ rank: Int = null + + """ + Added in 26.8.0. Minimum principal tier allowed to read the fragments under this entry. Defaults to the scope type's default (public=public, domain=authenticated, user=owner). + """ + readAccess: AppConfigAccessLevel = null + + """ + Added in 26.8.0. Minimum principal tier allowed to write the fragments under this entry. Defaults to the scope type's default (public=admin, domain=admin, user=owner). + """ + writeAccess: AppConfigAccessLevel = null } """Added in 26.7.0. Payload for app config allow-list entry creation.""" @@ -19333,7 +19362,7 @@ input UpdateAllowedResourceGroupsForProjectInput } """ -Added in 26.8.0. Input for updating an app config allow-list entry (rank only). +Added in 26.8.0. Input for updating an app config allow-list entry (rank / access tiers). """ input UpdateAppConfigAllowListInput @join__type(graph: STRAWBERRY) @@ -19345,6 +19374,16 @@ input UpdateAppConfigAllowListInput New merge rank applied to fragments under this entry (low to high; higher wins). Omit to leave unchanged. """ rank: Int = null + + """ + New minimum principal tier allowed to read the fragments under this entry. Omit to leave unchanged. + """ + readAccess: AppConfigAccessLevel = null + + """ + New minimum principal tier allowed to write the fragments under this entry. Omit to leave unchanged. + """ + writeAccess: AppConfigAccessLevel = null } """Added in 26.8.0. Payload for app config allow-list entry update.""" diff --git a/docs/manager/graphql-reference/v2-schema.graphql b/docs/manager/graphql-reference/v2-schema.graphql index 2b5fc2c44e1..f9d233d93c8 100644 --- a/docs/manager/graphql-reference/v2-schema.graphql +++ b/docs/manager/graphql-reference/v2-schema.graphql @@ -609,6 +609,13 @@ type AllowedResourceGroupsPayload { items: [String!]! } +enum AppConfigAccessLevel { + PUBLIC + AUTHENTICATED + OWNER + ADMIN +} + """ Added in 26.7.0. Permission to write config fragments for one config name at one scope type. """ @@ -627,6 +634,16 @@ type AppConfigAllowList implements Node { """ rank: Int! + """ + Added in 26.8.0. Minimum principal tier allowed to read the fragments under this entry. + """ + readAccess: AppConfigAccessLevel! + + """ + Added in 26.8.0. Minimum principal tier allowed to write the fragments under this entry. + """ + writeAccess: AppConfigAccessLevel! + """Creation timestamp (UTC).""" createdAt: DateTime! @@ -2345,6 +2362,16 @@ input CreateAppConfigAllowListInput { Added in 26.8.0. Merge rank applied to fragments under this entry (low to high; higher wins). Defaults to the scope type's default rank (public=100, domain=200, user=300). """ rank: Int = null + + """ + Added in 26.8.0. Minimum principal tier allowed to read the fragments under this entry. Defaults to the scope type's default (public=public, domain=authenticated, user=owner). + """ + readAccess: AppConfigAccessLevel = null + + """ + Added in 26.8.0. Minimum principal tier allowed to write the fragments under this entry. Defaults to the scope type's default (public=admin, domain=admin, user=owner). + """ + writeAccess: AppConfigAccessLevel = null } """Added in 26.7.0. Payload for app config allow-list entry creation.""" @@ -13512,7 +13539,7 @@ input UpdateAllowedResourceGroupsForProjectInput { } """ -Added in 26.8.0. Input for updating an app config allow-list entry (rank only). +Added in 26.8.0. Input for updating an app config allow-list entry (rank / access tiers). """ input UpdateAppConfigAllowListInput { """App config allow-list entry id to update.""" @@ -13522,6 +13549,16 @@ input UpdateAppConfigAllowListInput { New merge rank applied to fragments under this entry (low to high; higher wins). Omit to leave unchanged. """ rank: Int = null + + """ + New minimum principal tier allowed to read the fragments under this entry. Omit to leave unchanged. + """ + readAccess: AppConfigAccessLevel = null + + """ + New minimum principal tier allowed to write the fragments under this entry. Omit to leave unchanged. + """ + writeAccess: AppConfigAccessLevel = null } """Added in 26.8.0. Payload for app config allow-list entry update.""" diff --git a/docs/manager/rest-reference/openapi.json b/docs/manager/rest-reference/openapi.json index 6b66b82d41c..f536fe72daf 100644 --- a/docs/manager/rest-reference/openapi.json +++ b/docs/manager/rest-reference/openapi.json @@ -547,6 +547,37 @@ "title": "AdminSearchAgentsInput", "type": "object" }, + "ResolveAppConfigInput": { + "description": "Input for resolving the merged AppConfig for one ``(user, config_name)``.\n\n``user_id`` must match the authenticated caller (enforced by the adapter until an RBAC\nvalidator is in place).", + "properties": { + "config_name": { + "description": "Config name to resolve the merged view for.", + "maxLength": 128, + "minLength": 1, + "title": "Config Name", + "type": "string" + }, + "domain_id": { + "description": "The resolving user's domain id.", + "format": "uuid", + "title": "Domain Id", + "type": "string" + }, + "user_id": { + "description": "The resolving user's id (must be the caller).", + "format": "uuid", + "title": "User Id", + "type": "string" + } + }, + "required": [ + "config_name", + "domain_id", + "user_id" + ], + "title": "ResolveAppConfigInput", + "type": "object" + }, "AppConfigScopeType": { "description": "Scope at which an app config fragment is written (BEP-1052).\n\nThe single definition shared across the data, DTO, GraphQL, and API layers.", "enum": [ @@ -557,6 +588,548 @@ "title": "AppConfigScopeType", "type": "string" }, + "CreateAppConfigFragmentInput": { + "description": "Input for creating a new app config fragment at a given scope (superadmin only).", + "properties": { + "config_name": { + "description": "Registered config name (FK to app_config_definitions).", + "maxLength": 128, + "minLength": 1, + "title": "Config Name", + "type": "string" + }, + "scope_type": { + "$ref": "#/components/schemas/AppConfigScopeType", + "description": "Scope the fragment is written at (public | domain | user)." + }, + "scope_id": { + "description": "Scope identifier: 'public' for public, the domain name, or the user id.", + "title": "Scope Id", + "type": "string" + }, + "config": { + "additionalProperties": true, + "description": "The fragment's JSON config document.", + "title": "Config", + "type": "object" + } + }, + "required": [ + "config_name", + "scope_type", + "scope_id", + "config" + ], + "title": "CreateAppConfigFragmentInput", + "type": "object" + }, + "AppConfigFragmentFilter": { + "description": "Filter for app config fragment search.", + "properties": { + "config_name": { + "anyOf": [ + { + "$ref": "#/components/schemas/StringFilter" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Filter by config name." + }, + "scope_type": { + "anyOf": [ + { + "$ref": "#/components/schemas/AppConfigScopeTypeFilter" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Filter by scope type." + }, + "created_at": { + "anyOf": [ + { + "$ref": "#/components/schemas/DateTimeFilter" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Filter by creation datetime." + }, + "updated_at": { + "anyOf": [ + { + "$ref": "#/components/schemas/DateTimeFilter" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Filter by last update datetime." + } + }, + "title": "AppConfigFragmentFilter", + "type": "object" + }, + "AppConfigFragmentOrder": { + "description": "Order specifier for app config fragment search.", + "properties": { + "field": { + "$ref": "#/components/schemas/AppConfigFragmentOrderField", + "description": "Field to order by." + }, + "direction": { + "$ref": "#/components/schemas/OrderDirection", + "default": "ASC", + "description": "Order direction." + } + }, + "required": [ + "field" + ], + "title": "AppConfigFragmentOrder", + "type": "object" + }, + "AppConfigFragmentOrderField": { + "enum": [ + "config_name", + "scope_type", + "created_at", + "updated_at" + ], + "title": "AppConfigFragmentOrderField", + "type": "string" + }, + "AppConfigScopeTypeFilter": { + "description": "Filter for the scope_type enum field.", + "properties": { + "equals": { + "anyOf": [ + { + "$ref": "#/components/schemas/AppConfigScopeType" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Exact scope type match." + }, + "in": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/AppConfigScopeType" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Match any of the provided scope types.", + "title": "In" + }, + "not_equals": { + "anyOf": [ + { + "$ref": "#/components/schemas/AppConfigScopeType" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Exclude exact scope type match." + }, + "not_in": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/AppConfigScopeType" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Exclude any of the provided scope types.", + "title": "Not In" + } + }, + "title": "AppConfigScopeTypeFilter", + "type": "object" + }, + "DateTimeFilter": { + "description": "Filter for datetime fields supporting range and equality operations.", + "properties": { + "before": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Before this datetime (exclusive)", + "title": "Before" + }, + "after": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "After this datetime (exclusive)", + "title": "After" + }, + "equals": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Exact datetime match", + "title": "Equals" + }, + "not_equals": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Not equal to this datetime", + "title": "Not Equals" + } + }, + "title": "DateTimeFilter", + "type": "object" + }, + "SearchAppConfigFragmentInput": { + "description": "Input for paginated app config fragment search (superadmin only).", + "properties": { + "filter": { + "anyOf": [ + { + "$ref": "#/components/schemas/AppConfigFragmentFilter" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Filter conditions." + }, + "order": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/AppConfigFragmentOrder" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Order specifiers, applied in sequence.", + "title": "Order" + }, + "first": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Cursor-forward page size.", + "title": "First" + }, + "after": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Cursor-forward start cursor.", + "title": "After" + }, + "last": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Cursor-backward page size.", + "title": "Last" + }, + "before": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Cursor-backward end cursor.", + "title": "Before" + }, + "limit": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Offset-based: maximum number of results.", + "title": "Limit" + }, + "offset": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Offset-based: number of results to skip.", + "title": "Offset" + } + }, + "title": "SearchAppConfigFragmentInput", + "type": "object" + }, + "AppConfigFragmentScope": { + "description": "Scope for a scoped fragment search — OR across all domain/user items.\n\nRaises if every category is empty.", + "properties": { + "domain": { + "anyOf": [ + { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Domain ids whose fragments to search.", + "title": "Domain" + }, + "user": { + "anyOf": [ + { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "User ids whose fragments to search.", + "title": "User" + } + }, + "title": "AppConfigFragmentScope", + "type": "object" + }, + "ScopedSearchAppConfigFragmentInput": { + "description": "Input for a scoped fragment search keyed by domain/user scope (OR-combined).", + "properties": { + "scope": { + "$ref": "#/components/schemas/AppConfigFragmentScope", + "description": "Scope (OR across all items)." + }, + "order": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/AppConfigFragmentOrder" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Order specifiers, applied in sequence.", + "title": "Order" + }, + "first": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Cursor-forward page size.", + "title": "First" + }, + "after": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Cursor-forward start cursor.", + "title": "After" + }, + "last": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Cursor-backward page size.", + "title": "Last" + }, + "before": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Cursor-backward end cursor.", + "title": "Before" + }, + "limit": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Offset-based: maximum number of results.", + "title": "Limit" + }, + "offset": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Offset-based: number of results to skip.", + "title": "Offset" + } + }, + "required": [ + "scope" + ], + "title": "ScopedSearchAppConfigFragmentInput", + "type": "object" + }, + "UpdateAppConfigFragmentInput": { + "description": "Input for updating an app config fragment's config document.", + "properties": { + "config": { + "additionalProperties": true, + "description": "The replacement JSON config document.", + "title": "Config", + "type": "object" + } + }, + "required": [ + "config" + ], + "title": "UpdateAppConfigFragmentInput", + "type": "object" + }, + "AppConfigAccessLevel": { + "description": "Minimum principal tier allowed to read or write a config layer (BEP-1052).\n\nOrdered least → most privileged: ``public`` < ``authenticated`` < ``owner`` < ``admin``.\nStored per ``(config_name, scope_type)`` on the allow-list — once for read, once for\nwrite — so read-enablement and write-authorization are independent concerns. The\nallow-list row's existence no longer implies write access: it only registers the layer\nand its merge ``rank`` (the read side), while ``write_access`` decides who may write.\n\n``owner`` is resolved relative to the fragment's scope: the user itself for ``user``\nscope, the domain admin for ``domain``. ``admin`` (superadmin) always satisfies any tier.", + "enum": [ + "public", + "authenticated", + "owner", + "admin" + ], + "title": "AppConfigAccessLevel", + "type": "string" + }, "CreateAppConfigAllowListInput": { "description": "Input for registering a new app config allow-list entry.", "properties": { @@ -583,6 +1156,30 @@ "default": null, "description": "Merge rank applied to fragments under this entry (low to high; higher wins). Defaults to the scope type's default rank (public=100, domain=200, user=300).", "title": "Rank" + }, + "read_access": { + "anyOf": [ + { + "$ref": "#/components/schemas/AppConfigAccessLevel" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Minimum principal tier allowed to read the fragments under this entry. Defaults to the scope type's default (public=public, domain=authenticated, user=owner)." + }, + "write_access": { + "anyOf": [ + { + "$ref": "#/components/schemas/AppConfigAccessLevel" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Minimum principal tier allowed to write the fragments under this entry. Defaults to the scope type's default (public=admin, domain=admin, user=owner)." } }, "required": [ @@ -725,132 +1322,6 @@ "title": "AppConfigAllowListOrderField", "type": "string" }, - "AppConfigScopeTypeFilter": { - "description": "Filter for the scope_type enum field.", - "properties": { - "equals": { - "anyOf": [ - { - "$ref": "#/components/schemas/AppConfigScopeType" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Exact scope type match." - }, - "in": { - "anyOf": [ - { - "items": { - "$ref": "#/components/schemas/AppConfigScopeType" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Match any of the provided scope types.", - "title": "In" - }, - "not_equals": { - "anyOf": [ - { - "$ref": "#/components/schemas/AppConfigScopeType" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Exclude exact scope type match." - }, - "not_in": { - "anyOf": [ - { - "items": { - "$ref": "#/components/schemas/AppConfigScopeType" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Exclude any of the provided scope types.", - "title": "Not In" - } - }, - "title": "AppConfigScopeTypeFilter", - "type": "object" - }, - "DateTimeFilter": { - "description": "Filter for datetime fields supporting range and equality operations.", - "properties": { - "before": { - "anyOf": [ - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Before this datetime (exclusive)", - "title": "Before" - }, - "after": { - "anyOf": [ - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "After this datetime (exclusive)", - "title": "After" - }, - "equals": { - "anyOf": [ - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Exact datetime match", - "title": "Equals" - }, - "not_equals": { - "anyOf": [ - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Not equal to this datetime", - "title": "Not Equals" - } - }, - "title": "DateTimeFilter", - "type": "object" - }, "SearchAppConfigAllowListInput": { "description": "Input for paginated app config allow-list search.", "properties": { @@ -969,7 +1440,7 @@ "type": "object" }, "UpdateAppConfigAllowListInput": { - "description": "Input for updating an app config allow-list entry.\n\nOnly ``rank`` is updatable — the identity pair (``config_name``, ``scope_type``)\nis immutable (purge and recreate to change it).", + "description": "Input for updating an app config allow-list entry.\n\n``rank`` and the access tiers (``read_access`` / ``write_access``) are updatable — the\nidentity pair (``config_name``, ``scope_type``) is immutable (purge and recreate to\nchange it).", "properties": { "id": { "description": "App config allow-list entry id to update.", @@ -989,6 +1460,30 @@ "default": null, "description": "New merge rank applied to fragments under this entry (low to high; higher wins). Omit to leave unchanged.", "title": "Rank" + }, + "read_access": { + "anyOf": [ + { + "$ref": "#/components/schemas/AppConfigAccessLevel" + }, + { + "type": "null" + } + ], + "default": null, + "description": "New minimum principal tier allowed to read the fragments under this entry. Omit to leave unchanged." + }, + "write_access": { + "anyOf": [ + { + "$ref": "#/components/schemas/AppConfigAccessLevel" + }, + { + "type": "null" + } + ], + "default": null, + "description": "New minimum principal tier allowed to write the fragments under this entry. Omit to leave unchanged." } }, "required": [ @@ -38698,6 +39193,238 @@ "description": "Retrieve aggregate resource capacity/usage across all agents (superadmin only).\n\n**Preconditions:**\n* Superadmin privilege required.\n" } }, + "/v2/app-config/resolve": { + "post": { + "operationId": "v2/app-config.resolve", + "tags": [ + "v2/app-config" + ], + "responses": { + "200": { + "description": "Successful response" + } + }, + "security": [ + { + "TokenAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResolveAppConfigInput" + } + } + } + }, + "parameters": [], + "description": "Resolve the merged AppConfig for one (user, config_name) (auth required).\n\n**Preconditions:**\n* User privilege required.\n" + } + }, + "/v2/app-config/public/{config_name}": { + "get": { + "operationId": "v2/app-config.resolve_public", + "tags": [ + "v2/app-config" + ], + "responses": { + "200": { + "description": "Successful response" + } + }, + "parameters": [ + { + "name": "config_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "description": "Resolve the merged AppConfig from public fragments only (anonymous)." + } + }, + "/v2/app-config-fragments/": { + "post": { + "operationId": "v2/app-config-fragments.create", + "tags": [ + "v2/app-config-fragments" + ], + "responses": { + "200": { + "description": "Successful response" + } + }, + "security": [ + { + "TokenAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAppConfigFragmentInput" + } + } + } + }, + "parameters": [], + "description": "Create a fragment; authorized by the layer's write_access tier (auth required).\n\n**Preconditions:**\n* User privilege required.\n" + } + }, + "/v2/app-config-fragments/search": { + "post": { + "operationId": "v2/app-config-fragments.admin_search", + "tags": [ + "v2/app-config-fragments" + ], + "responses": { + "200": { + "description": "Successful response" + } + }, + "security": [ + { + "TokenAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchAppConfigFragmentInput" + } + } + } + }, + "parameters": [], + "description": "Search fragments across all scopes with pagination (superadmin only).\n\n**Preconditions:**\n* Superadmin privilege required.\n" + } + }, + "/v2/app-config-fragments/scoped-search": { + "post": { + "operationId": "v2/app-config-fragments.scoped_search", + "tags": [ + "v2/app-config-fragments" + ], + "responses": { + "200": { + "description": "Successful response" + } + }, + "security": [ + { + "TokenAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScopedSearchAppConfigFragmentInput" + } + } + } + }, + "parameters": [], + "description": "Search the fragments visible to the calling principal (auth required).\n\n**Preconditions:**\n* User privilege required.\n" + } + }, + "/v2/app-config-fragments/{fragment_id}": { + "get": { + "operationId": "v2/app-config-fragments.admin_get", + "tags": [ + "v2/app-config-fragments" + ], + "responses": { + "200": { + "description": "Successful response" + } + }, + "security": [ + { + "TokenAuth": [] + } + ], + "parameters": [ + { + "name": "fragment_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "description": "Get a fragment by id (superadmin only).\n\n**Preconditions:**\n* Superadmin privilege required.\n" + }, + "patch": { + "operationId": "v2/app-config-fragments.update", + "tags": [ + "v2/app-config-fragments" + ], + "responses": { + "200": { + "description": "Successful response" + } + }, + "security": [ + { + "TokenAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAppConfigFragmentInput" + } + } + } + }, + "parameters": [ + { + "name": "fragment_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "description": "Update a fragment's config; authorized by the layer's write_access tier (auth required).\n\n**Preconditions:**\n* User privilege required.\n" + }, + "delete": { + "operationId": "v2/app-config-fragments.purge", + "tags": [ + "v2/app-config-fragments" + ], + "responses": { + "200": { + "description": "Successful response" + } + }, + "security": [ + { + "TokenAuth": [] + } + ], + "parameters": [ + { + "name": "fragment_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "description": "Purge a fragment by id; authorized by the layer's write_access tier (auth required).\n\n**Preconditions:**\n* User privilege required.\n" + } + }, "/v2/app-config-allow-list/": { "post": { "operationId": "v2/app-config-allow-list.admin_create", diff --git a/src/ai/backend/client/cli/v2/admin/app_config_allow_list.py b/src/ai/backend/client/cli/v2/admin/app_config_allow_list.py index d9c171e1e95..40c6f22f83d 100644 --- a/src/ai/backend/client/cli/v2/admin/app_config_allow_list.py +++ b/src/ai/backend/client/cli/v2/admin/app_config_allow_list.py @@ -13,7 +13,7 @@ parse_order_options, print_result, ) -from ai.backend.common.data.app_config.types import AppConfigScopeType +from ai.backend.common.data.app_config.types import AppConfigAccessLevel, AppConfigScopeType @click.group() @@ -38,7 +38,31 @@ def app_config_allow_list() -> None: "Defaults to the scope type's default rank (public=100, domain=200, user=300)." ), ) -def create(config_name: str, scope_type: str, rank: int | None) -> None: +@click.option( + "--read-access", + default=None, + type=click.Choice([level.value for level in AppConfigAccessLevel]), + help=( + "Minimum principal tier allowed to read the fragments under this entry. " + "Defaults to the scope type's default (public=public, domain=authenticated, user=owner)." + ), +) +@click.option( + "--write-access", + default=None, + type=click.Choice([level.value for level in AppConfigAccessLevel]), + help=( + "Minimum principal tier allowed to write the fragments under this entry. " + "Defaults to the scope type's default (public=admin, domain=admin, user=owner)." + ), +) +def create( + config_name: str, + scope_type: str, + rank: int | None, + read_access: str | None, + write_access: str | None, +) -> None: """Register a new app config allow-list entry.""" from ai.backend.common.dto.manager.v2.app_config_allow_list.request import ( CreateAppConfigAllowListInput, @@ -52,6 +76,12 @@ async def _run() -> None: config_name=config_name, scope_type=AppConfigScopeType(scope_type), rank=rank, + read_access=( + AppConfigAccessLevel(read_access) if read_access is not None else None + ), + write_access=( + AppConfigAccessLevel(write_access) if write_access is not None else None + ), ) ) print_result(result) @@ -160,12 +190,38 @@ async def _run() -> None: @click.argument("app_config_allow_list_id", type=click.UUID) @click.option( "--rank", - required=True, + default=None, type=int, - help=("New merge rank applied to fragments under this entry (low to high; higher wins)."), + help=( + "New merge rank applied to fragments under this entry (low to high; higher wins). " + "Omit to leave unchanged." + ), ) -def update(app_config_allow_list_id: uuid.UUID, rank: int) -> None: - """Update an app config allow-list entry's rank by ID.""" +@click.option( + "--read-access", + default=None, + type=click.Choice([level.value for level in AppConfigAccessLevel]), + help=( + "New minimum principal tier allowed to read the fragments under this entry. " + "Omit to leave unchanged." + ), +) +@click.option( + "--write-access", + default=None, + type=click.Choice([level.value for level in AppConfigAccessLevel]), + help=( + "New minimum principal tier allowed to write the fragments under this entry. " + "Omit to leave unchanged." + ), +) +def update( + app_config_allow_list_id: uuid.UUID, + rank: int | None, + read_access: str | None, + write_access: str | None, +) -> None: + """Update an app config allow-list entry's rank / access tiers by ID.""" from ai.backend.common.dto.manager.v2.app_config_allow_list.request import ( UpdateAppConfigAllowListInput, ) @@ -175,7 +231,16 @@ async def _run() -> None: try: result = await registry.app_config_allow_list.admin_update( app_config_allow_list_id, - UpdateAppConfigAllowListInput(id=app_config_allow_list_id, rank=rank), + UpdateAppConfigAllowListInput( + id=app_config_allow_list_id, + rank=rank, + read_access=( + AppConfigAccessLevel(read_access) if read_access is not None else None + ), + write_access=( + AppConfigAccessLevel(write_access) if write_access is not None else None + ), + ), ) print_result(result) finally: diff --git a/src/ai/backend/client/v2/domains_v2/app_config_allow_list.py b/src/ai/backend/client/v2/domains_v2/app_config_allow_list.py index 9be278d1c21..d265b220f96 100644 --- a/src/ai/backend/client/v2/domains_v2/app_config_allow_list.py +++ b/src/ai/backend/client/v2/domains_v2/app_config_allow_list.py @@ -27,7 +27,7 @@ class V2AppConfigAllowListClient(BaseDomainClient): Mirrors the REST v2 surface introduced in BA-6546. All calls require super-admin privileges; the entity supports create, get, search, update - (rank only), and purge. + (rank and read/write access tiers), and purge. """ async def admin_create( @@ -67,7 +67,7 @@ async def admin_update( app_config_allow_list_id: UUID, request: UpdateAppConfigAllowListInput, ) -> UpdateAppConfigAllowListPayload: - """Update an app config allow-list entry's rank by ID (superadmin only).""" + """Update an app config allow-list entry's rank / access tiers by ID (superadmin only).""" return await self._client.typed_request( "PATCH", f"{_PATH}/{app_config_allow_list_id}", diff --git a/src/ai/backend/common/data/app_config/types.py b/src/ai/backend/common/data/app_config/types.py index a473c714f00..f1964d6838d 100644 --- a/src/ai/backend/common/data/app_config/types.py +++ b/src/ai/backend/common/data/app_config/types.py @@ -5,8 +5,61 @@ import enum from ai.backend.common.data.permission.types import ScopeType +from ai.backend.common.data.user.types import UserData -__all__ = ("AppConfigScopeType",) +__all__ = ("AppConfigScopeType", "AppConfigAccessLevel") + + +class AppConfigAccessLevel(enum.StrEnum): + """Minimum principal tier allowed to read or write a config layer (BEP-1052). + + Ordered least → most privileged: ``public`` < ``authenticated`` < ``owner`` < ``admin``. + Stored per ``(config_name, scope_type)`` on the allow-list — once for read, once for + write — so read-enablement and write-authorization are independent concerns. The + allow-list row's existence no longer implies write access: it only registers the layer + and its merge ``rank`` (the read side), while ``write_access`` decides who may write. + + ``owner`` is resolved relative to the fragment's scope: the user itself for ``user`` + scope, the domain admin for ``domain``. ``admin`` (superadmin) always satisfies any tier. + """ + + PUBLIC = "public" + AUTHENTICATED = "authenticated" + OWNER = "owner" + ADMIN = "admin" + + def is_satisfied_by( + self, user: UserData | None, scope_type: AppConfigScopeType, scope_id: str + ) -> bool: + """Whether ``user`` meets this access tier for a fragment at ``(scope_type, scope_id)``. + + Anonymous (``user is None``) satisfies only ``public``. A superadmin satisfies every + tier. ``owner`` is instance-relative: the user itself for ``user`` scope, a domain + admin for ``domain`` scope; ``public`` scope has no owner. + """ + if user is None: + return self is AppConfigAccessLevel.PUBLIC + if user.is_superadmin: + return True + match self: + case AppConfigAccessLevel.PUBLIC: + return True + case AppConfigAccessLevel.AUTHENTICATED: + return user.is_authorized + case AppConfigAccessLevel.OWNER: + match scope_type: + case AppConfigScopeType.USER: + return str(user.user_id) == scope_id + case AppConfigScopeType.DOMAIN: + # NOTE: assumes a domain fragment's scope_id holds the domain *name* + # (matching UserData.domain_name). Not exercised by the default policy + # (domain write defaults to ADMIN); verify when domain-owner writes land. + return user.is_admin and user.domain_name == scope_id + case AppConfigScopeType.PUBLIC: + return False + case AppConfigAccessLevel.ADMIN: + # superadmin already returned True above; no one else satisfies ADMIN + return False class AppConfigScopeType(enum.StrEnum): @@ -57,3 +110,31 @@ def default_rank(self) -> int: return 200 case AppConfigScopeType.USER: return 300 + + def default_read_access(self) -> AppConfigAccessLevel: + """Default read access for an allow-list entry at this scope type (BEP-1052). + + ``public`` is world-readable (anonymous included); ``domain`` is readable by any + authenticated caller; a ``user`` layer is readable only by its owner. + """ + match self: + case AppConfigScopeType.PUBLIC: + return AppConfigAccessLevel.PUBLIC + case AppConfigScopeType.DOMAIN: + return AppConfigAccessLevel.AUTHENTICATED + case AppConfigScopeType.USER: + return AppConfigAccessLevel.OWNER + + def default_write_access(self) -> AppConfigAccessLevel: + """Default write access for an allow-list entry at this scope type (BEP-1052). + + ``public`` / ``domain`` layers are admin-owned; a ``user`` layer is writable by its + owner, so an allow-listed user manages their own user-scope fragment without admin. + """ + match self: + case AppConfigScopeType.PUBLIC: + return AppConfigAccessLevel.ADMIN + case AppConfigScopeType.DOMAIN: + return AppConfigAccessLevel.ADMIN + case AppConfigScopeType.USER: + return AppConfigAccessLevel.OWNER diff --git a/src/ai/backend/common/dto/manager/v2/app_config/__init__.py b/src/ai/backend/common/dto/manager/v2/app_config/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/common/dto/manager/v2/app_config/request.py b/src/ai/backend/common/dto/manager/v2/app_config/request.py new file mode 100644 index 00000000000..c13250b9475 --- /dev/null +++ b/src/ai/backend/common/dto/manager/v2/app_config/request.py @@ -0,0 +1,25 @@ +"""Request DTOs for the merged app_config v2 read.""" + +from __future__ import annotations + +from uuid import UUID + +from pydantic import Field + +from ai.backend.common.api_handlers import BaseRequestModel + +__all__ = ("ResolveAppConfigInput",) + + +class ResolveAppConfigInput(BaseRequestModel): + """Input for resolving the merged AppConfig for one ``(user, config_name)``. + + ``user_id`` must match the authenticated caller (enforced by the adapter until an RBAC + validator is in place). + """ + + config_name: str = Field( + min_length=1, max_length=128, description="Config name to resolve the merged view for." + ) + domain_id: UUID = Field(description="The resolving user's domain id.") + user_id: UUID = Field(description="The resolving user's id (must be the caller).") diff --git a/src/ai/backend/common/dto/manager/v2/app_config/response.py b/src/ai/backend/common/dto/manager/v2/app_config/response.py new file mode 100644 index 00000000000..59acdbcdedb --- /dev/null +++ b/src/ai/backend/common/dto/manager/v2/app_config/response.py @@ -0,0 +1,34 @@ +"""Response DTOs for the merged app_config v2 read.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import Field + +from ai.backend.common.api_handlers import BaseResponseModel +from ai.backend.common.dto.manager.v2.app_config_fragment.response import AppConfigFragmentNode + +__all__ = ( + "AppConfigNode", + "ResolveAppConfigPayload", +) + + +class AppConfigNode(BaseResponseModel): + """The merged AppConfig view for one config name.""" + + config_name: str = Field(description="Config name this merged view is for.") + merged_config: dict[str, Any] | None = Field( + description="Deep-merged config in ascending allow-list rank order; null when no fragment " + "contributes (the config name is defined but unconfigured for this scope)." + ) + fragments: list[AppConfigFragmentNode] = Field( + description="The fragments that contributed to the merge, in ascending allow-list rank order." + ) + + +class ResolveAppConfigPayload(BaseResponseModel): + """Payload for a merged AppConfig resolve.""" + + app_config: AppConfigNode = Field(description="The merged AppConfig view.") diff --git a/src/ai/backend/common/dto/manager/v2/app_config_allow_list/request.py b/src/ai/backend/common/dto/manager/v2/app_config_allow_list/request.py index 90a05941bdb..16cd07736f1 100644 --- a/src/ai/backend/common/dto/manager/v2/app_config_allow_list/request.py +++ b/src/ai/backend/common/dto/manager/v2/app_config_allow_list/request.py @@ -8,7 +8,7 @@ from pydantic import Field from ai.backend.common.api_handlers import BaseRequestModel -from ai.backend.common.data.app_config.types import AppConfigScopeType +from ai.backend.common.data.app_config.types import AppConfigAccessLevel, AppConfigScopeType from ai.backend.common.dto.manager.query import DateTimeFilter, StringFilter from ai.backend.common.dto.manager.v2.app_config_allow_list.types import ( AppConfigAllowListOrderField, @@ -44,13 +44,29 @@ class CreateAppConfigAllowListInput(BaseRequestModel): "Defaults to the scope type's default rank (public=100, domain=200, user=300)." ), ) + read_access: AppConfigAccessLevel | None = Field( + default=None, + description=( + "Minimum principal tier allowed to read the fragments under this entry. " + "Defaults to the scope type's default (public=public, domain=authenticated, " + "user=owner)." + ), + ) + write_access: AppConfigAccessLevel | None = Field( + default=None, + description=( + "Minimum principal tier allowed to write the fragments under this entry. " + "Defaults to the scope type's default (public=admin, domain=admin, user=owner)." + ), + ) class UpdateAppConfigAllowListInput(BaseRequestModel): """Input for updating an app config allow-list entry. - Only ``rank`` is updatable — the identity pair (``config_name``, ``scope_type``) - is immutable (purge and recreate to change it). + ``rank`` and the access tiers (``read_access`` / ``write_access``) are updatable — the + identity pair (``config_name``, ``scope_type``) is immutable (purge and recreate to + change it). """ id: UUID = Field(description="App config allow-list entry id to update.") @@ -61,6 +77,20 @@ class UpdateAppConfigAllowListInput(BaseRequestModel): "higher wins). Omit to leave unchanged." ), ) + read_access: AppConfigAccessLevel | None = Field( + default=None, + description=( + "New minimum principal tier allowed to read the fragments under this entry. " + "Omit to leave unchanged." + ), + ) + write_access: AppConfigAccessLevel | None = Field( + default=None, + description=( + "New minimum principal tier allowed to write the fragments under this entry. " + "Omit to leave unchanged." + ), + ) class PurgeAppConfigAllowListInput(BaseRequestModel): diff --git a/src/ai/backend/common/dto/manager/v2/app_config_allow_list/response.py b/src/ai/backend/common/dto/manager/v2/app_config_allow_list/response.py index 0484b9489d1..314e1801685 100644 --- a/src/ai/backend/common/dto/manager/v2/app_config_allow_list/response.py +++ b/src/ai/backend/common/dto/manager/v2/app_config_allow_list/response.py @@ -8,7 +8,7 @@ from pydantic import Field from ai.backend.common.api_handlers import BaseResponseModel -from ai.backend.common.data.app_config.types import AppConfigScopeType +from ai.backend.common.data.app_config.types import AppConfigAccessLevel, AppConfigScopeType __all__ = ( "AppConfigAllowListNode", @@ -28,6 +28,12 @@ class AppConfigAllowListNode(BaseResponseModel): rank: int = Field( description=("Merge rank applied to fragments under this entry (low to high; higher wins).") ) + read_access: AppConfigAccessLevel = Field( + description="Minimum principal tier allowed to read the fragments under this entry." + ) + write_access: AppConfigAccessLevel = Field( + description="Minimum principal tier allowed to write the fragments under this entry." + ) created_at: datetime = Field(description="Creation timestamp (UTC).") updated_at: datetime = Field(description="Last update timestamp (UTC).") diff --git a/src/ai/backend/common/dto/manager/v2/app_config_fragment/__init__.py b/src/ai/backend/common/dto/manager/v2/app_config_fragment/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/common/dto/manager/v2/app_config_fragment/request.py b/src/ai/backend/common/dto/manager/v2/app_config_fragment/request.py new file mode 100644 index 00000000000..e09c12dc282 --- /dev/null +++ b/src/ai/backend/common/dto/manager/v2/app_config_fragment/request.py @@ -0,0 +1,137 @@ +"""Request DTOs for app_config_fragment v2.""" + +from __future__ import annotations + +from typing import Any, Self +from uuid import UUID + +from pydantic import Field, model_validator + +from ai.backend.common.api_handlers import BaseRequestModel +from ai.backend.common.data.app_config.types import AppConfigScopeType +from ai.backend.common.dto.manager.query import DateTimeFilter, StringFilter +from ai.backend.common.dto.manager.v2.app_config_fragment.types import ( + AppConfigFragmentOrderField, + AppConfigScopeTypeFilter, +) +from ai.backend.common.dto.manager.v2.common import OrderDirection + +__all__ = ( + "AppConfigFragmentFilter", + "AppConfigFragmentOrder", + "AppConfigFragmentScope", + "CreateAppConfigFragmentInput", + "PurgeAppConfigFragmentInput", + "ScopedSearchAppConfigFragmentInput", + "SearchAppConfigFragmentInput", + "UpdateAppConfigFragmentInput", +) + + +class CreateAppConfigFragmentInput(BaseRequestModel): + """Input for creating a new app config fragment at a given scope (superadmin only).""" + + config_name: str = Field( + min_length=1, + max_length=128, + description="Registered config name (FK to app_config_definitions).", + ) + scope_type: AppConfigScopeType = Field( + description="Scope the fragment is written at (public | domain | user)." + ) + scope_id: str = Field( + description="Scope identifier: 'public' for public, the domain name, or the user id.", + ) + config: dict[str, Any] = Field(description="The fragment's JSON config document.") + + +class UpdateAppConfigFragmentInput(BaseRequestModel): + """Input for updating an app config fragment's config document.""" + + config: dict[str, Any] = Field(description="The replacement JSON config document.") + + +class PurgeAppConfigFragmentInput(BaseRequestModel): + """Input for purging an app config fragment.""" + + id: UUID = Field(description="App config fragment id to purge.") + + +class AppConfigFragmentFilter(BaseRequestModel): + """Filter for app config fragment search.""" + + config_name: StringFilter | None = Field(default=None, description="Filter by config name.") + scope_type: AppConfigScopeTypeFilter | None = Field( + default=None, description="Filter by scope type." + ) + created_at: DateTimeFilter | None = Field( + default=None, description="Filter by creation datetime." + ) + updated_at: DateTimeFilter | None = Field( + default=None, description="Filter by last update datetime." + ) + + +class AppConfigFragmentOrder(BaseRequestModel): + """Order specifier for app config fragment search.""" + + field: AppConfigFragmentOrderField = Field(description="Field to order by.") + direction: OrderDirection = Field(default=OrderDirection.ASC, description="Order direction.") + + +class SearchAppConfigFragmentInput(BaseRequestModel): + """Input for paginated app config fragment search (superadmin only).""" + + filter: AppConfigFragmentFilter | None = Field(default=None, description="Filter conditions.") + order: list[AppConfigFragmentOrder] | None = Field( + default=None, description="Order specifiers, applied in sequence." + ) + first: int | None = Field(default=None, ge=1, description="Cursor-forward page size.") + after: str | None = Field(default=None, description="Cursor-forward start cursor.") + last: int | None = Field(default=None, ge=1, description="Cursor-backward page size.") + before: str | None = Field(default=None, description="Cursor-backward end cursor.") + limit: int | None = Field( + default=None, ge=1, description="Offset-based: maximum number of results." + ) + offset: int | None = Field( + default=None, ge=0, description="Offset-based: number of results to skip." + ) + + +class AppConfigFragmentScope(BaseRequestModel): + """Scope for a scoped fragment search — OR across all domain/user items. + + Raises if every category is empty. + """ + + domain: list[UUID] | None = Field( + default=None, description="Domain ids whose fragments to search." + ) + user: list[UUID] | None = Field(default=None, description="User ids whose fragments to search.") + + @model_validator(mode="after") + def _require_non_empty(self) -> Self: + if not self.domain and not self.user: + raise ValueError( + "AppConfigFragmentScope requires a non-empty value for 'domain' or 'user'" + ) + return self + + +class ScopedSearchAppConfigFragmentInput(BaseRequestModel): + """Input for a scoped fragment search keyed by domain/user scope (OR-combined).""" + + scope: AppConfigFragmentScope = Field(description="Scope (OR across all items).") + order: list[AppConfigFragmentOrder] | None = Field( + default=None, description="Order specifiers, applied in sequence." + ) + first: int | None = Field(default=None, ge=1, description="Cursor-forward page size.") + after: str | None = Field(default=None, description="Cursor-forward start cursor.") + last: int | None = Field(default=None, ge=1, description="Cursor-backward page size.") + before: str | None = Field(default=None, description="Cursor-backward end cursor.") + limit: int | None = Field( + default=None, ge=1, description="Offset-based: maximum number of results." + ) + offset: int | None = Field( + default=None, ge=0, description="Offset-based: number of results to skip." + ) diff --git a/src/ai/backend/common/dto/manager/v2/app_config_fragment/response.py b/src/ai/backend/common/dto/manager/v2/app_config_fragment/response.py new file mode 100644 index 00000000000..c36a616b8f2 --- /dev/null +++ b/src/ai/backend/common/dto/manager/v2/app_config_fragment/response.py @@ -0,0 +1,59 @@ +"""Response DTOs for app_config_fragment v2.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import UUID + +from pydantic import Field + +from ai.backend.common.api_handlers import BaseResponseModel +from ai.backend.common.data.app_config.types import AppConfigScopeType + +__all__ = ( + "AppConfigFragmentNode", + "CreateAppConfigFragmentPayload", + "PurgeAppConfigFragmentPayload", + "SearchAppConfigFragmentPayload", + "UpdateAppConfigFragmentPayload", +) + + +class AppConfigFragmentNode(BaseResponseModel): + """Node model representing one app config fragment.""" + + id: UUID = Field(description="App config fragment UUID.") + config_name: str = Field(description="Config name the fragment belongs to.") + scope_type: AppConfigScopeType = Field(description="Scope the fragment is written at.") + scope_id: str = Field(description="Scope identifier (public / domain name / user id).") + config: dict[str, Any] = Field(description="The fragment's JSON config document.") + created_at: datetime = Field(description="Creation timestamp (UTC).") + updated_at: datetime = Field(description="Last update timestamp (UTC).") + + +class CreateAppConfigFragmentPayload(BaseResponseModel): + """Payload for app config fragment creation.""" + + app_config_fragment: AppConfigFragmentNode = Field(description="Created app config fragment.") + + +class UpdateAppConfigFragmentPayload(BaseResponseModel): + """Payload for app config fragment update.""" + + app_config_fragment: AppConfigFragmentNode = Field(description="Updated app config fragment.") + + +class PurgeAppConfigFragmentPayload(BaseResponseModel): + """Payload for app config fragment purge.""" + + id: UUID = Field(description="UUID of the purged app config fragment.") + + +class SearchAppConfigFragmentPayload(BaseResponseModel): + """Payload for paginated app config fragment search results.""" + + items: list[AppConfigFragmentNode] = Field(description="App config fragment nodes.") + total_count: int = Field(description="Total count matching the query.") + has_next_page: bool = Field(description="Whether there is a next page.") + has_previous_page: bool = Field(description="Whether there is a previous page.") diff --git a/src/ai/backend/common/dto/manager/v2/app_config_fragment/types.py b/src/ai/backend/common/dto/manager/v2/app_config_fragment/types.py new file mode 100644 index 00000000000..4315987a53c --- /dev/null +++ b/src/ai/backend/common/dto/manager/v2/app_config_fragment/types.py @@ -0,0 +1,37 @@ +"""Enum types and filters for app_config_fragment v2 DTOs.""" + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import Field + +from ai.backend.common.api_handlers import BaseRequestModel +from ai.backend.common.data.app_config.types import AppConfigScopeType + +__all__ = ( + "AppConfigScopeTypeFilter", + "AppConfigFragmentOrderField", +) + + +class AppConfigScopeTypeFilter(BaseRequestModel): + """Filter for the scope_type enum field.""" + + equals: AppConfigScopeType | None = Field(default=None, description="Exact scope type match.") + in_: list[AppConfigScopeType] | None = Field( + default=None, alias="in", description="Match any of the provided scope types." + ) + not_equals: AppConfigScopeType | None = Field( + default=None, description="Exclude exact scope type match." + ) + not_in: list[AppConfigScopeType] | None = Field( + default=None, description="Exclude any of the provided scope types." + ) + + +class AppConfigFragmentOrderField(StrEnum): + CONFIG_NAME = "config_name" + SCOPE_TYPE = "scope_type" + CREATED_AT = "created_at" + UPDATED_AT = "updated_at" diff --git a/src/ai/backend/manager/api/adapters/app_config/__init__.py b/src/ai/backend/manager/api/adapters/app_config/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/manager/api/adapters/app_config/adapter.py b/src/ai/backend/manager/api/adapters/app_config/adapter.py new file mode 100644 index 00000000000..931a1c2a856 --- /dev/null +++ b/src/ai/backend/manager/api/adapters/app_config/adapter.py @@ -0,0 +1,349 @@ +"""App config adapter bridging v2 DTOs and the fragment / merged-config Processors.""" + +from __future__ import annotations + +from functools import lru_cache + +from ai.backend.common.contexts.user import current_user +from ai.backend.common.data.app_config.types import AppConfigScopeType +from ai.backend.common.data.app_config.types import AppConfigScopeType as AppConfigScopeTypeDTO +from ai.backend.common.dto.manager.v2.app_config.request import ResolveAppConfigInput +from ai.backend.common.dto.manager.v2.app_config.response import ( + AppConfigNode, + ResolveAppConfigPayload, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + AppConfigFragmentFilter, + AppConfigFragmentOrder, + CreateAppConfigFragmentInput, + PurgeAppConfigFragmentInput, + ScopedSearchAppConfigFragmentInput, + SearchAppConfigFragmentInput, + UpdateAppConfigFragmentInput, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.response import ( + AppConfigFragmentNode, + CreateAppConfigFragmentPayload, + PurgeAppConfigFragmentPayload, + SearchAppConfigFragmentPayload, + UpdateAppConfigFragmentPayload, +) +from ai.backend.common.dto.manager.v2.app_config_fragment.types import ( + AppConfigFragmentOrderField, + AppConfigScopeTypeFilter, +) +from ai.backend.common.dto.manager.v2.common import OrderDirection +from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID +from ai.backend.common.identifier.domain import DomainID +from ai.backend.common.identifier.user import UserID +from ai.backend.manager.actions.action.types import SearchableActionTarget +from ai.backend.manager.api.adapter_options.pagination.pagination import PaginationSpec +from ai.backend.manager.api.adapters.base import BaseAdapter +from ai.backend.manager.data.app_config.types import AppConfigData +from ai.backend.manager.data.app_config_fragment.types import ( + AppConfigFragmentData, +) +from ai.backend.manager.errors.app_config import AppConfigResolveNotAllowed +from ai.backend.manager.models.app_config_fragment.conditions import AppConfigFragmentConditions +from ai.backend.manager.models.app_config_fragment.orders import AppConfigFragmentOrders +from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow +from ai.backend.manager.models.clauses import QueryCondition, QueryOrder +from ai.backend.manager.repositories.app_config_fragment.creators import ( + AppConfigFragmentCreatorSpec, +) +from ai.backend.manager.repositories.app_config_fragment.types import AppConfigScopeArguments +from ai.backend.manager.repositories.app_config_fragment.updaters import ( + AppConfigFragmentUpdaterSpec, +) +from ai.backend.manager.repositories.base import ( + Purger, + Updater, +) +from ai.backend.manager.services.app_config.actions.resolve import ResolveAppConfigAction +from ai.backend.manager.services.app_config_fragment.actions.admin_search import ( + AdminSearchAppConfigFragmentAction, +) +from ai.backend.manager.services.app_config_fragment.actions.create import ( + CreateAppConfigFragmentAction, +) +from ai.backend.manager.services.app_config_fragment.actions.get import ( + GetAppConfigFragmentAction, +) +from ai.backend.manager.services.app_config_fragment.actions.purge import ( + PurgeAppConfigFragmentAction, +) +from ai.backend.manager.services.app_config_fragment.actions.scoped_search import ( + DomainAppConfigFragmentTarget, + ScopedSearchAppConfigFragmentAction, + UserAppConfigFragmentTarget, +) +from ai.backend.manager.services.app_config_fragment.actions.update import ( + UpdateAppConfigFragmentAction, +) +from ai.backend.manager.types import OptionalState + + +@lru_cache(maxsize=1) +def _get_app_config_fragment_pagination_spec() -> PaginationSpec: + return PaginationSpec( + forward_order=AppConfigFragmentOrders.created_at(ascending=False), + backward_order=AppConfigFragmentOrders.created_at(ascending=True), + forward_condition_factory=AppConfigFragmentConditions.by_cursor_forward, + backward_condition_factory=AppConfigFragmentConditions.by_cursor_backward, + tiebreaker_order=AppConfigFragmentOrders.id(ascending=True), + ) + + +class AppConfigAdapter(BaseAdapter): + """Adapter for app config fragment (write/search) and merged AppConfig (read) operations.""" + + # --- admin fragment CRUD --- + + async def create(self, input: CreateAppConfigFragmentInput) -> CreateAppConfigFragmentPayload: + spec = AppConfigFragmentCreatorSpec( + config_name=input.config_name, + scope_type=AppConfigScopeType(input.scope_type.value), + scope_id=input.scope_id, + config=input.config, + ) + # Authorization is enforced in the service against the layer's write_access tier. + action_result = await self._processors.app_config_fragment.create.wait_for_complete( + CreateAppConfigFragmentAction(creator_spec=spec, requester=current_user()) + ) + return CreateAppConfigFragmentPayload( + app_config_fragment=self._fragment_to_node(action_result.fragment), + ) + + async def admin_get(self, fragment_id: AppConfigFragmentID) -> AppConfigFragmentNode: + action_result = await self._processors.app_config_fragment.get.wait_for_complete( + GetAppConfigFragmentAction(fragment_id=fragment_id) + ) + return self._fragment_to_node(action_result.fragment) + + async def update( + self, fragment_id: AppConfigFragmentID, input: UpdateAppConfigFragmentInput + ) -> UpdateAppConfigFragmentPayload: + updater = Updater( + spec=AppConfigFragmentUpdaterSpec(config=OptionalState.update(input.config)), + pk_value=fragment_id, + ) + # Authorization is enforced in the service: it loads the fragment and checks the + # caller against the layer's write_access tier before updating. + action_result = await self._processors.app_config_fragment.update.wait_for_complete( + UpdateAppConfigFragmentAction(updater=updater, requester=current_user()) + ) + return UpdateAppConfigFragmentPayload( + app_config_fragment=self._fragment_to_node(action_result.fragment), + ) + + async def purge(self, input: PurgeAppConfigFragmentInput) -> PurgeAppConfigFragmentPayload: + fragment_id = AppConfigFragmentID(input.id) + purger = Purger(row_class=AppConfigFragmentRow, pk_value=fragment_id) + # Authorization is enforced in the service — see ``update``. + action_result = await self._processors.app_config_fragment.purge.wait_for_complete( + PurgeAppConfigFragmentAction(purger=purger, requester=current_user()) + ) + return PurgeAppConfigFragmentPayload(id=action_result.fragment.id) + + async def admin_search( + self, input: SearchAppConfigFragmentInput + ) -> SearchAppConfigFragmentPayload: + conditions = self._convert_filter(input.filter) if input.filter else [] + orders = self._convert_orders(input.order) if input.order else [] + querier = self._build_querier( + conditions=conditions, + orders=orders, + pagination_spec=_get_app_config_fragment_pagination_spec(), + first=input.first, + after=input.after, + last=input.last, + before=input.before, + limit=input.limit, + offset=input.offset, + ) + action_result = await self._processors.app_config_fragment.admin_search.wait_for_complete( + AdminSearchAppConfigFragmentAction(querier=querier) + ) + return SearchAppConfigFragmentPayload( + items=[self._fragment_to_node(item) for item in action_result.data], + total_count=action_result.total_count, + has_next_page=action_result.has_next_page, + has_previous_page=action_result.has_previous_page, + ) + + # --- scoped fragment search (principal-visibility) --- + + async def scoped_search( + self, input: ScopedSearchAppConfigFragmentInput + ) -> SearchAppConfigFragmentPayload: + orders = self._convert_orders(input.order) if input.order else [] + querier = self._build_querier( + conditions=[], + orders=orders, + pagination_spec=_get_app_config_fragment_pagination_spec(), + first=input.first, + after=input.after, + last=input.last, + before=input.before, + limit=input.limit, + offset=input.offset, + ) + targets: list[SearchableActionTarget] = [ + DomainAppConfigFragmentTarget(domain_id=DomainID(domain_id)) + for domain_id in (input.scope.domain or []) + ] + targets += [ + UserAppConfigFragmentTarget(user_id=UserID(user_id)) + for user_id in (input.scope.user or []) + ] + action_result = await self._processors.app_config_fragment.scoped_search.wait_for_complete( + ScopedSearchAppConfigFragmentAction( + items=targets, querier=querier, requester=current_user() + ) + ) + return SearchAppConfigFragmentPayload( + items=[self._fragment_to_node(item) for item in action_result.data], + total_count=action_result.total_count, + has_next_page=action_result.has_next_page, + has_previous_page=action_result.has_previous_page, + ) + + # --- merged AppConfig read --- + + async def resolve(self, input: ResolveAppConfigInput) -> ResolveAppConfigPayload: + self._ensure_caller(input.user_id) + action_result = await self._processors.app_config.resolve_app_config.wait_for_complete( + ResolveAppConfigAction( + config_name=input.config_name, + scope=AppConfigScopeArguments( + domain_id=DomainID(input.domain_id), user_id=UserID(input.user_id) + ), + requester=current_user(), + ) + ) + return ResolveAppConfigPayload( + app_config=self._app_config_to_node(action_result.app_config) + ) + + async def resolve_public(self, config_name: str) -> ResolveAppConfigPayload: + # Anonymous, pre-login read: no principal (``scope=None``) and no ``requester``, so + # only public, public-readable fragments contribute. Reuses the standard resolve + # action rather than a separate public one. + action_result = await self._processors.app_config.resolve_app_config.wait_for_complete( + ResolveAppConfigAction(config_name=config_name, requester=None) + ) + return ResolveAppConfigPayload( + app_config=self._app_config_to_node(action_result.app_config) + ) + + # --- guards / converters --- + + @staticmethod + def _ensure_caller(user_id: object) -> None: + """Reject a request whose ``user_id`` is not the authenticated caller. + + Temporary stand-in for an RBAC validator: the processors carry no validator, so the + only principal check happens here. + """ + me = current_user() + if me is None or user_id != me.user_id: + raise AppConfigResolveNotAllowed( + "App config resolve is only allowed for the authenticated caller." + ) + + @staticmethod + def _fragment_to_node(data: AppConfigFragmentData) -> AppConfigFragmentNode: + return AppConfigFragmentNode( + id=data.id, + config_name=data.config_name, + scope_type=AppConfigScopeTypeDTO(data.scope_type.value), + scope_id=data.scope_id, + config=data.config, + created_at=data.created_at, + updated_at=data.updated_at, + ) + + @classmethod + def _app_config_to_node(cls, data: AppConfigData) -> AppConfigNode: + return AppConfigNode( + config_name=data.config_name, + merged_config=data.merged_config, + fragments=[cls._fragment_to_node(fragment) for fragment in data.fragments], + ) + + def _convert_filter(self, filter_: AppConfigFragmentFilter) -> list[QueryCondition]: + conditions: list[QueryCondition] = [] + if filter_.config_name: + condition = self.convert_string_filter( + filter_.config_name, + contains_factory=AppConfigFragmentConditions.by_config_name_contains, + equals_factory=AppConfigFragmentConditions.by_config_name_equals, + starts_with_factory=AppConfigFragmentConditions.by_config_name_starts_with, + ends_with_factory=AppConfigFragmentConditions.by_config_name_ends_with, + in_factory=AppConfigFragmentConditions.by_config_name_in, + ) + if condition: + conditions.append(condition) + if filter_.scope_type: + conditions.extend(self._convert_scope_type_filter(filter_.scope_type)) + if filter_.created_at: + condition = filter_.created_at.build_query_condition( + before_factory=AppConfigFragmentConditions.by_created_at_before, + after_factory=AppConfigFragmentConditions.by_created_at_after, + equals_factory=AppConfigFragmentConditions.by_created_at_equals, + ) + if condition: + conditions.append(condition) + if filter_.updated_at: + condition = filter_.updated_at.build_query_condition( + before_factory=AppConfigFragmentConditions.by_updated_at_before, + after_factory=AppConfigFragmentConditions.by_updated_at_after, + equals_factory=AppConfigFragmentConditions.by_updated_at_equals, + ) + if condition: + conditions.append(condition) + return conditions + + @staticmethod + def _convert_scope_type_filter(filter_: AppConfigScopeTypeFilter) -> list[QueryCondition]: + conditions: list[QueryCondition] = [] + if filter_.equals is not None: + conditions.append( + AppConfigFragmentConditions.by_scope_type_equals( + AppConfigScopeType(filter_.equals.value) + ) + ) + if filter_.in_ is not None: + conditions.append( + AppConfigFragmentConditions.by_scope_type_in([ + AppConfigScopeType(value.value) for value in filter_.in_ + ]) + ) + if filter_.not_equals is not None: + conditions.append( + AppConfigFragmentConditions.by_scope_type_not_equals( + AppConfigScopeType(filter_.not_equals.value) + ) + ) + if filter_.not_in is not None: + conditions.append( + AppConfigFragmentConditions.by_scope_type_not_in([ + AppConfigScopeType(value.value) for value in filter_.not_in + ]) + ) + return conditions + + def _convert_orders(self, orders: list[AppConfigFragmentOrder]) -> list[QueryOrder]: + result: list[QueryOrder] = [] + for order in orders: + ascending = order.direction == OrderDirection.ASC + match order.field: + case AppConfigFragmentOrderField.CONFIG_NAME: + result.append(AppConfigFragmentOrders.config_name(ascending)) + case AppConfigFragmentOrderField.SCOPE_TYPE: + result.append(AppConfigFragmentOrders.scope_type(ascending)) + case AppConfigFragmentOrderField.CREATED_AT: + result.append(AppConfigFragmentOrders.created_at(ascending)) + case AppConfigFragmentOrderField.UPDATED_AT: + result.append(AppConfigFragmentOrders.updated_at(ascending)) + return result diff --git a/src/ai/backend/manager/api/adapters/app_config_allow_list/adapter.py b/src/ai/backend/manager/api/adapters/app_config_allow_list/adapter.py index 6205d7fa2ec..fea6f780e81 100644 --- a/src/ai/backend/manager/api/adapters/app_config_allow_list/adapter.py +++ b/src/ai/backend/manager/api/adapters/app_config_allow_list/adapter.py @@ -92,6 +92,8 @@ async def admin_create( config_name=input.config_name, scope_type=AppConfigScopeType(input.scope_type.value), rank=input.rank, + read_access=input.read_access, + write_access=input.write_access, ) ) action_result = await self._processors.app_config_allow_list.create.wait_for_complete( @@ -164,6 +166,16 @@ async def admin_update( if input.rank is not None else OptionalState.nop() ), + read_access=( + OptionalState.update(input.read_access) + if input.read_access is not None + else OptionalState.nop() + ), + write_access=( + OptionalState.update(input.write_access) + if input.write_access is not None + else OptionalState.nop() + ), ), pk_value=AppConfigAllowListID(input.id), ) @@ -190,6 +202,8 @@ def _data_to_node(data: AppConfigAllowListData) -> AppConfigAllowListNode: config_name=data.config_name, scope_type=AppConfigScopeTypeDTO(data.scope_type.value), rank=data.rank, + read_access=data.read_access, + write_access=data.write_access, created_at=data.created_at, updated_at=data.updated_at, ) diff --git a/src/ai/backend/manager/api/adapters/registry.py b/src/ai/backend/manager/api/adapters/registry.py index 173ce640e59..4f4c30762cc 100644 --- a/src/ai/backend/manager/api/adapters/registry.py +++ b/src/ai/backend/manager/api/adapters/registry.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING from ai.backend.manager.api.adapters.agent.adapter import AgentAdapter +from ai.backend.manager.api.adapters.app_config.adapter import AppConfigAdapter from ai.backend.manager.api.adapters.app_config_allow_list.adapter import ( AppConfigAllowListAdapter, ) @@ -78,6 +79,7 @@ class Adapters: def __init__( self, agent: AgentAdapter, + app_config: AppConfigAdapter, app_config_allow_list: AppConfigAllowListAdapter, app_config_definition: AppConfigDefinitionAdapter, artifact: ArtifactAdapter, @@ -121,6 +123,7 @@ def __init__( vfs_storage: VFSStorageAdapter, ) -> None: self.agent = agent + self.app_config = app_config self.app_config_allow_list = app_config_allow_list self.app_config_definition = app_config_definition self.artifact = artifact @@ -183,6 +186,7 @@ def create( """ return cls( agent=AgentAdapter(processors), + app_config=AppConfigAdapter(processors), app_config_allow_list=AppConfigAllowListAdapter(processors), app_config_definition=AppConfigDefinitionAdapter(processors), artifact=ArtifactAdapter(processors), diff --git a/src/ai/backend/manager/api/gql/app_config_allow_list/types.py b/src/ai/backend/manager/api/gql/app_config_allow_list/types.py index 8fc9a0267f1..6b5ac01e83d 100644 --- a/src/ai/backend/manager/api/gql/app_config_allow_list/types.py +++ b/src/ai/backend/manager/api/gql/app_config_allow_list/types.py @@ -11,7 +11,7 @@ from strawberry import Info from strawberry.relay import Connection, Edge, NodeID -from ai.backend.common.data.app_config.types import AppConfigScopeType +from ai.backend.common.data.app_config.types import AppConfigAccessLevel, AppConfigScopeType from ai.backend.common.dto.manager.v2.app_config_allow_list.request import ( AppConfigAllowListFilter as AppConfigAllowListFilterDTO, ) @@ -104,6 +104,18 @@ class AppConfigAllowListGQL(PydanticNodeMixin[AppConfigAllowListNode]): ), ), ) + read_access: AppConfigAccessLevel = gql_added_field( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Minimum principal tier allowed to read the fragments under this entry.", + ), + ) + write_access: AppConfigAccessLevel = gql_added_field( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Minimum principal tier allowed to write the fragments under this entry.", + ), + ) created_at: datetime = gql_field(description="Creation timestamp (UTC).") updated_at: datetime = gql_field(description="Last update timestamp (UTC).") @@ -254,6 +266,27 @@ class CreateAppConfigAllowListInputGQL(PydanticInputMixin[CreateAppConfigAllowLi ), default=None, ) + read_access: AppConfigAccessLevel | None = gql_added_field( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description=( + "Minimum principal tier allowed to read the fragments under this entry. " + "Defaults to the scope type's default (public=public, domain=authenticated, " + "user=owner)." + ), + ), + default=None, + ) + write_access: AppConfigAccessLevel | None = gql_added_field( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description=( + "Minimum principal tier allowed to write the fragments under this entry. " + "Defaults to the scope type's default (public=admin, domain=admin, user=owner)." + ), + ), + default=None, + ) @gql_pydantic_type( @@ -272,7 +305,7 @@ class CreateAppConfigAllowListPayloadGQL(PydanticOutputMixin[CreateAppConfigAllo @gql_pydantic_input( BackendAIGQLMeta( - description="Input for updating an app config allow-list entry (rank only).", + description="Input for updating an app config allow-list entry (rank / access tiers).", added_version=NEXT_RELEASE_VERSION, ), name="UpdateAppConfigAllowListInput", @@ -286,6 +319,20 @@ class UpdateAppConfigAllowListInputGQL(PydanticInputMixin[UpdateAppConfigAllowLi ), default=None, ) + read_access: AppConfigAccessLevel | None = gql_field( + description=( + "New minimum principal tier allowed to read the fragments under this entry. " + "Omit to leave unchanged." + ), + default=None, + ) + write_access: AppConfigAccessLevel | None = gql_field( + description=( + "New minimum principal tier allowed to write the fragments under this entry. " + "Omit to leave unchanged." + ), + default=None, + ) @gql_pydantic_type( diff --git a/src/ai/backend/manager/api/rest/v2/app_config/__init__.py b/src/ai/backend/manager/api/rest/v2/app_config/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/manager/api/rest/v2/app_config/handler.py b/src/ai/backend/manager/api/rest/v2/app_config/handler.py new file mode 100644 index 00000000000..af0e61cb5de --- /dev/null +++ b/src/ai/backend/manager/api/rest/v2/app_config/handler.py @@ -0,0 +1,40 @@ +"""REST v2 handler for the merged app config (read) domain.""" + +from __future__ import annotations + +import logging +from http import HTTPStatus +from typing import TYPE_CHECKING, Final + +from ai.backend.common.api_handlers import APIResponse, BodyParam, PathParam +from ai.backend.common.dto.manager.v2.app_config.request import ResolveAppConfigInput +from ai.backend.logging import BraceStyleAdapter +from ai.backend.manager.api.rest.v2.path_params import AppConfigConfigNamePathParam + +if TYPE_CHECKING: + from ai.backend.manager.api.adapters.app_config.adapter import AppConfigAdapter + +log: Final = BraceStyleAdapter(logging.getLogger(__spec__.name)) + + +class V2AppConfigHandler: + """REST v2 handler for the merged AppConfig read operations.""" + + def __init__(self, *, adapter: AppConfigAdapter) -> None: + self._adapter = adapter + + async def resolve( + self, + body: BodyParam[ResolveAppConfigInput], + ) -> APIResponse: + """Resolve the merged AppConfig for one (user, config_name) (auth required).""" + result = await self._adapter.resolve(body.parsed) + return APIResponse.build(status_code=HTTPStatus.OK, response_model=result) + + async def resolve_public( + self, + path: PathParam[AppConfigConfigNamePathParam], + ) -> APIResponse: + """Resolve the merged AppConfig from public fragments only (anonymous).""" + result = await self._adapter.resolve_public(path.parsed.config_name) + return APIResponse.build(status_code=HTTPStatus.OK, response_model=result) diff --git a/src/ai/backend/manager/api/rest/v2/app_config/registry.py b/src/ai/backend/manager/api/rest/v2/app_config/registry.py new file mode 100644 index 00000000000..eeee84a9497 --- /dev/null +++ b/src/ai/backend/manager/api/rest/v2/app_config/registry.py @@ -0,0 +1,32 @@ +"""Route registry for REST v2 merged app config endpoints.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ai.backend.manager.api.rest.middleware.auth import auth_required +from ai.backend.manager.api.rest.routing import RouteRegistry + +from .handler import V2AppConfigHandler + +if TYPE_CHECKING: + from ai.backend.manager.api.rest.types import RouteDeps + + +def register_v2_app_config_routes( + handler: V2AppConfigHandler, + route_deps: RouteDeps, +) -> RouteRegistry: + """Register all REST v2 merged app config routes. + + Layout: + POST /resolve resolve merged config for a principal (auth) + GET /public/{config_name} resolve merged public config (anonymous) + """ + registry = RouteRegistry.create("app-config", route_deps.cors_options) + + registry.add("POST", "/resolve", handler.resolve, middlewares=[auth_required]) + # Anonymous (pre-login) public read: no auth middleware. + registry.add("GET", "/public/{config_name}", handler.resolve_public) + + return registry diff --git a/src/ai/backend/manager/api/rest/v2/app_config_fragment/__init__.py b/src/ai/backend/manager/api/rest/v2/app_config_fragment/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/manager/api/rest/v2/app_config_fragment/handler.py b/src/ai/backend/manager/api/rest/v2/app_config_fragment/handler.py new file mode 100644 index 00000000000..80a01434847 --- /dev/null +++ b/src/ai/backend/manager/api/rest/v2/app_config_fragment/handler.py @@ -0,0 +1,82 @@ +"""REST v2 handler for the app config fragment domain.""" + +from __future__ import annotations + +import logging +from http import HTTPStatus +from typing import TYPE_CHECKING, Final + +from ai.backend.common.api_handlers import APIResponse, BodyParam, PathParam +from ai.backend.common.dto.manager.v2.app_config_fragment.request import ( + CreateAppConfigFragmentInput, + PurgeAppConfigFragmentInput, + ScopedSearchAppConfigFragmentInput, + SearchAppConfigFragmentInput, + UpdateAppConfigFragmentInput, +) +from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID +from ai.backend.logging import BraceStyleAdapter +from ai.backend.manager.api.rest.v2.path_params import AppConfigFragmentIdPathParam + +if TYPE_CHECKING: + from ai.backend.manager.api.adapters.app_config.adapter import AppConfigAdapter + +log: Final = BraceStyleAdapter(logging.getLogger(__spec__.name)) + + +class V2AppConfigFragmentHandler: + """REST v2 handler for raw app config fragment operations.""" + + def __init__(self, *, adapter: AppConfigAdapter) -> None: + self._adapter = adapter + + async def create( + self, + body: BodyParam[CreateAppConfigFragmentInput], + ) -> APIResponse: + """Create a fragment; authorized by the layer's write_access tier (auth required).""" + result = await self._adapter.create(body.parsed) + return APIResponse.build(status_code=HTTPStatus.CREATED, response_model=result) + + async def admin_get( + self, + path: PathParam[AppConfigFragmentIdPathParam], + ) -> APIResponse: + """Get a fragment by id (superadmin only).""" + result = await self._adapter.admin_get(AppConfigFragmentID(path.parsed.fragment_id)) + return APIResponse.build(status_code=HTTPStatus.OK, response_model=result) + + async def update( + self, + path: PathParam[AppConfigFragmentIdPathParam], + body: BodyParam[UpdateAppConfigFragmentInput], + ) -> APIResponse: + """Update a fragment's config; authorized by the layer's write_access tier (auth required).""" + result = await self._adapter.update( + AppConfigFragmentID(path.parsed.fragment_id), body.parsed + ) + return APIResponse.build(status_code=HTTPStatus.OK, response_model=result) + + async def purge( + self, + path: PathParam[AppConfigFragmentIdPathParam], + ) -> APIResponse: + """Purge a fragment by id; authorized by the layer's write_access tier (auth required).""" + result = await self._adapter.purge(PurgeAppConfigFragmentInput(id=path.parsed.fragment_id)) + return APIResponse.build(status_code=HTTPStatus.OK, response_model=result) + + async def admin_search( + self, + body: BodyParam[SearchAppConfigFragmentInput], + ) -> APIResponse: + """Search fragments across all scopes with pagination (superadmin only).""" + result = await self._adapter.admin_search(body.parsed) + return APIResponse.build(status_code=HTTPStatus.OK, response_model=result) + + async def scoped_search( + self, + body: BodyParam[ScopedSearchAppConfigFragmentInput], + ) -> APIResponse: + """Search the fragments visible to the calling principal (auth required).""" + result = await self._adapter.scoped_search(body.parsed) + return APIResponse.build(status_code=HTTPStatus.OK, response_model=result) diff --git a/src/ai/backend/manager/api/rest/v2/app_config_fragment/registry.py b/src/ai/backend/manager/api/rest/v2/app_config_fragment/registry.py new file mode 100644 index 00000000000..8240b638f41 --- /dev/null +++ b/src/ai/backend/manager/api/rest/v2/app_config_fragment/registry.py @@ -0,0 +1,41 @@ +"""Route registry for REST v2 app config fragment endpoints.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ai.backend.manager.api.rest.middleware.auth import auth_required, superadmin_required +from ai.backend.manager.api.rest.routing import RouteRegistry + +from .handler import V2AppConfigFragmentHandler + +if TYPE_CHECKING: + from ai.backend.manager.api.rest.types import RouteDeps + + +def register_v2_app_config_fragment_routes( + handler: V2AppConfigFragmentHandler, + route_deps: RouteDeps, +) -> RouteRegistry: + """Register all REST v2 app config fragment routes. + + Layout: + POST / create a fragment (auth; write_access tier) + POST /search admin paginated search (superadmin) + POST /scoped-search principal-visible search (auth) + GET /{fragment_id} get by id (superadmin) + PATCH /{fragment_id} update config by id (auth; write_access tier) + DELETE /{fragment_id} purge by id (auth; write_access tier) + """ + registry = RouteRegistry.create("app-config-fragments", route_deps.cors_options) + + # Write routes are auth_required; the service authorizes each write against the target + # layer's allow_list write_access tier (a superadmin passes any tier). + registry.add("POST", "/", handler.create, middlewares=[auth_required]) + registry.add("POST", "/search", handler.admin_search, middlewares=[superadmin_required]) + registry.add("POST", "/scoped-search", handler.scoped_search, middlewares=[auth_required]) + registry.add("GET", "/{fragment_id}", handler.admin_get, middlewares=[superadmin_required]) + registry.add("PATCH", "/{fragment_id}", handler.update, middlewares=[auth_required]) + registry.add("DELETE", "/{fragment_id}", handler.purge, middlewares=[auth_required]) + + return registry diff --git a/src/ai/backend/manager/api/rest/v2/path_params.py b/src/ai/backend/manager/api/rest/v2/path_params.py index f90faac77ee..5077c909976 100644 --- a/src/ai/backend/manager/api/rest/v2/path_params.py +++ b/src/ai/backend/manager/api/rest/v2/path_params.py @@ -94,6 +94,14 @@ class AppConfigDefinitionIdPathParam(BaseRequestModel): app_config_definition_id: UUID = Field(description="App config definition UUID") +class AppConfigFragmentIdPathParam(BaseRequestModel): + fragment_id: UUID = Field(description="App config fragment UUID") + + +class AppConfigConfigNamePathParam(BaseRequestModel): + config_name: str = Field(description="App config name") + + class ReplicaIdPathParam(BaseRequestModel): replica_id: UUID = Field(description="Replica UUID") diff --git a/src/ai/backend/manager/api/rest/v2/tree.py b/src/ai/backend/manager/api/rest/v2/tree.py index 5f6e6b0bf3b..e894e6f2eeb 100644 --- a/src/ai/backend/manager/api/rest/v2/tree.py +++ b/src/ai/backend/manager/api/rest/v2/tree.py @@ -28,10 +28,14 @@ def build_v2_routes( # Lazy imports to avoid circular dependencies at module level from .agent.handler import V2AgentHandler from .agent.registry import register_v2_agent_routes + from .app_config.handler import V2AppConfigHandler + from .app_config.registry import register_v2_app_config_routes from .app_config_allow_list.handler import V2AppConfigAllowListHandler from .app_config_allow_list.registry import register_v2_app_config_allow_list_routes from .app_config_definition.handler import V2AppConfigDefinitionHandler from .app_config_definition.registry import register_v2_app_config_definition_routes + from .app_config_fragment.handler import V2AppConfigFragmentHandler + from .app_config_fragment.registry import register_v2_app_config_fragment_routes from .artifact.handler import V2ArtifactHandler from .artifact.registry import register_v2_artifact_routes from .artifact_registry.handler import V2ArtifactRegistryHandler @@ -121,6 +125,8 @@ def build_v2_routes( # Build all handlers (each takes its individual adapter) agent_handler = V2AgentHandler(adapter=adapters.agent) + app_config_handler = V2AppConfigHandler(adapter=adapters.app_config) + app_config_fragment_handler = V2AppConfigFragmentHandler(adapter=adapters.app_config) app_config_allow_list_handler = V2AppConfigAllowListHandler( adapter=adapters.app_config_allow_list ) @@ -184,6 +190,10 @@ def build_v2_routes( # Add all domain sub-registries v2_reg.add_subregistry(register_v2_agent_routes(agent_handler, route_deps)) + v2_reg.add_subregistry(register_v2_app_config_routes(app_config_handler, route_deps)) + v2_reg.add_subregistry( + register_v2_app_config_fragment_routes(app_config_fragment_handler, route_deps) + ) v2_reg.add_subregistry( register_v2_app_config_allow_list_routes(app_config_allow_list_handler, route_deps) ) diff --git a/src/ai/backend/manager/data/app_config/__init__.py b/src/ai/backend/manager/data/app_config/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/manager/data/app_config/types.py b/src/ai/backend/manager/data/app_config/types.py new file mode 100644 index 00000000000..cab9d719514 --- /dev/null +++ b/src/ai/backend/manager/data/app_config/types.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from ai.backend.manager.data.app_config_fragment.types import AppConfigFragmentData + + +@dataclass(frozen=True) +class AppConfigData: + """Merged per-user view of one ``config_name``. + + The ordered contributing ``fragments`` (rank low -> high) plus their deep-merged + ``merged_config``. ``merged_config`` is ``None`` when no fragment contributes (the config + name is defined but unconfigured for this scope) — distinct from a fragment that merges + to an empty ``{}``. + """ + + config_name: str + fragments: list[AppConfigFragmentData] + merged_config: dict[str, Any] | None diff --git a/src/ai/backend/manager/data/app_config_allow_list/types.py b/src/ai/backend/manager/data/app_config_allow_list/types.py index 470e2cd102b..4bb545a406d 100644 --- a/src/ai/backend/manager/data/app_config_allow_list/types.py +++ b/src/ai/backend/manager/data/app_config_allow_list/types.py @@ -3,22 +3,25 @@ from dataclasses import dataclass from datetime import datetime -from ai.backend.common.data.app_config.types import AppConfigScopeType +from ai.backend.common.data.app_config.types import AppConfigAccessLevel, AppConfigScopeType from ai.backend.common.identifier.app_config_allow_list import AppConfigAllowListID @dataclass(frozen=True) class AppConfigAllowListData: - """Domain data for one app config allow-list entry — a per-``(config_name, scope_type)`` write gate. + """Domain data for one app config allow-list entry, keyed per ``(config_name, scope_type)``. ``rank`` is the merge priority applied to every fragment written under the entry - (low → high; higher wins). + (low → high; higher wins). ``read_access`` / ``write_access`` are the admin-owned tiers + that decide who may read / write the layer — independent of the entry's existence. """ id: AppConfigAllowListID config_name: str scope_type: AppConfigScopeType rank: int + read_access: AppConfigAccessLevel + write_access: AppConfigAccessLevel created_at: datetime updated_at: datetime diff --git a/src/ai/backend/manager/data/app_config_fragment/types.py b/src/ai/backend/manager/data/app_config_fragment/types.py index cd5f451acdd..6bde39270e4 100644 --- a/src/ai/backend/manager/data/app_config_fragment/types.py +++ b/src/ai/backend/manager/data/app_config_fragment/types.py @@ -4,7 +4,7 @@ from datetime import datetime from typing import Any -from ai.backend.common.data.app_config.types import AppConfigScopeType +from ai.backend.common.data.app_config.types import AppConfigAccessLevel, AppConfigScopeType from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID @@ -21,6 +21,19 @@ class AppConfigFragmentData: updated_at: datetime +@dataclass(frozen=True) +class VisibleFragment: + """A visible fragment paired with its layer's ``read_access`` tier. + + The read query joins each fragment to its ``(config_name, scope_type)`` allow-list entry; + ``read_access`` is carried alongside so the service can drop layers the reader's tier does + not satisfy before merging (read authorization). + """ + + data: AppConfigFragmentData + read_access: AppConfigAccessLevel + + @dataclass(frozen=True) class AppConfigFragmentSearchResult: """Search result with total count for app config fragments.""" diff --git a/src/ai/backend/manager/errors/app_config.py b/src/ai/backend/manager/errors/app_config.py index 139ff0b357a..34095d47184 100644 --- a/src/ai/backend/manager/errors/app_config.py +++ b/src/ai/backend/manager/errors/app_config.py @@ -7,6 +7,7 @@ "AppConfigDefinitionNotFound", "AppConfigFragmentNotFound", "AppConfigFragmentWriteNotAllowed", + "AppConfigResolveNotAllowed", ) @@ -34,3 +35,14 @@ class AppConfigFragmentWriteNotAllowed(GenericForbidden): error_type = "https://api.backend.ai/probs/app-config-fragment-write-not-allowed" error_title = "App config fragment write is not allowed for this config/scope." + + +class AppConfigResolveNotAllowed(GenericForbidden): + """A merged-AppConfig read was requested for a principal other than the caller. + + Temporary per-request guard standing in for an RBAC validator: a resolve / scoped + search whose ``user_id`` does not match the authenticated caller is rejected. + """ + + error_type = "https://api.backend.ai/probs/app-config-resolve-not-allowed" + error_title = "App config resolve is not allowed for this principal." diff --git a/src/ai/backend/manager/models/alembic/versions/3e7b1d9c4a25_add_app_config_allow_list_access_levels.py b/src/ai/backend/manager/models/alembic/versions/3e7b1d9c4a25_add_app_config_allow_list_access_levels.py new file mode 100644 index 00000000000..1eace7087d7 --- /dev/null +++ b/src/ai/backend/manager/models/alembic/versions/3e7b1d9c4a25_add_app_config_allow_list_access_levels.py @@ -0,0 +1,63 @@ +"""add app_config_allow_list read/write access levels + +Add ``read_access`` / ``write_access`` to ``app_config_allow_list`` (BEP-1052). +The allow-list row registers a config layer and carries its merge ``rank`` (the +read side); its mere existence used to double as the write gate, conflating +read-enablement with write-authorization. These admin-owned tiers make the two +concerns independent: who may read and who may write a layer are now explicit. +Existing rows are backfilled with the per-scope-type default policy +(public: read=public/write=admin, domain: read=authenticated/write=admin, +user: read=owner/write=owner). + +Revision ID: 3e7b1d9c4a25 +Revises: a3c1d8e5b294 +Create Date: 2026-07-10 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "3e7b1d9c4a25" +down_revision = "a3c1d8e5b294" +# Part of: NEXT_RELEASE_VERSION +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "app_config_allow_list", + sa.Column("read_access", sa.String(length=64), nullable=True), + ) + op.add_column( + "app_config_allow_list", + sa.Column("write_access", sa.String(length=64), nullable=True), + ) + # Backfill by the scope-type default policy. + op.execute( + sa.text( + """ + UPDATE app_config_allow_list + SET read_access = CASE scope_type + WHEN 'public' THEN 'public' + WHEN 'domain' THEN 'authenticated' + WHEN 'user' THEN 'owner' + END, + write_access = CASE scope_type + WHEN 'public' THEN 'admin' + WHEN 'domain' THEN 'admin' + WHEN 'user' THEN 'owner' + END + WHERE read_access IS NULL OR write_access IS NULL + """ + ) + ) + op.alter_column("app_config_allow_list", "read_access", nullable=False) + op.alter_column("app_config_allow_list", "write_access", nullable=False) + + +def downgrade() -> None: + op.drop_column("app_config_allow_list", "write_access") + op.drop_column("app_config_allow_list", "read_access") diff --git a/src/ai/backend/manager/models/app_config_allow_list/row.py b/src/ai/backend/manager/models/app_config_allow_list/row.py index f9cdce75b76..8c1e96f0fa9 100644 --- a/src/ai/backend/manager/models/app_config_allow_list/row.py +++ b/src/ai/backend/manager/models/app_config_allow_list/row.py @@ -5,7 +5,7 @@ import sqlalchemy as sa from sqlalchemy.orm import Mapped, mapped_column -from ai.backend.common.data.app_config.types import AppConfigScopeType +from ai.backend.common.data.app_config.types import AppConfigAccessLevel, AppConfigScopeType from ai.backend.common.identifier.app_config_allow_list import AppConfigAllowListID from ai.backend.manager.data.app_config_allow_list.types import ( AppConfigAllowListData, @@ -16,16 +16,18 @@ class AppConfigAllowListRow(Base): # type: ignore[misc] - """Permission to write config fragments for one config name at one scope type. + """Registration of one config layer: a ``(config_name, scope_type)`` and its access policy. - A config fragment may be created only when a matching row exists here. - Deletion cascades both ways: fragments reference this table by - ``(config_name, scope_type)`` and this table references - ``app_config_definitions`` by ``config_name``, both ``ON DELETE CASCADE`` — - so retiring a config name clears its entries and fragments. ``rank`` is the - merge priority the entry's fragments carry (low → high; higher wins) — - admin-owned so fragment owners cannot re-order the merge. At most one row per - ``(config_name, scope_type)``; only admins create or remove these rows. + A config fragment may exist only when a matching row exists here — the row *registers* + the layer (it is not itself a write grant). Deletion cascades both ways: fragments + reference this table by ``(config_name, scope_type)`` and this table references + ``app_config_definitions`` by ``config_name``, both ``ON DELETE CASCADE`` — so retiring a + config name clears its entries and fragments. ``rank`` is the merge priority the entry's + fragments carry (low → high; higher wins) — admin-owned so fragment owners cannot + re-order the merge. ``read_access`` / ``write_access`` are the admin-owned tiers deciding + who may read / write the layer, keeping read-enablement and write-authorization + independent of the row's mere existence. At most one row per ``(config_name, scope_type)``; + only admins create or remove these rows. """ __tablename__ = "app_config_allow_list" @@ -57,6 +59,16 @@ class AppConfigAllowListRow(Base): # type: ignore[misc] sa.Integer, nullable=False, ) + read_access: Mapped[AppConfigAccessLevel] = mapped_column( + "read_access", + StrEnumType(AppConfigAccessLevel), + nullable=False, + ) + write_access: Mapped[AppConfigAccessLevel] = mapped_column( + "write_access", + StrEnumType(AppConfigAccessLevel), + nullable=False, + ) created_at: Mapped[datetime] = mapped_column( "created_at", sa.DateTime(timezone=True), @@ -77,6 +89,8 @@ def to_data(self) -> AppConfigAllowListData: config_name=self.config_name, scope_type=self.scope_type, rank=self.rank, + read_access=self.read_access, + write_access=self.write_access, created_at=self.created_at, updated_at=self.updated_at, ) diff --git a/src/ai/backend/manager/repositories/app_config_allow_list/creators.py b/src/ai/backend/manager/repositories/app_config_allow_list/creators.py index f1be2e4ec91..840a0f079f2 100644 --- a/src/ai/backend/manager/repositories/app_config_allow_list/creators.py +++ b/src/ai/backend/manager/repositories/app_config_allow_list/creators.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import override -from ai.backend.common.data.app_config.types import AppConfigScopeType +from ai.backend.common.data.app_config.types import AppConfigAccessLevel, AppConfigScopeType from ai.backend.manager.models.app_config_allow_list.row import AppConfigAllowListRow from ai.backend.manager.repositories.base import CreatorSpec @@ -17,17 +17,34 @@ class AppConfigAllowListCreatorSpec(CreatorSpec[AppConfigAllowListRow]): ``rank`` is the merge priority every fragment under the entry carries; when not given, it falls back to the scope type's default (public=100, domain=200, user=300), which orders the scopes so a user's own fragment wins the merge. + ``read_access`` / ``write_access`` likewise fall back to the scope type's default + policy (public: read=public/write=admin, domain: read=authenticated/write=admin, + user: read=owner/write=owner) when not given. """ config_name: str scope_type: AppConfigScopeType rank: int | None = None + read_access: AppConfigAccessLevel | None = None + write_access: AppConfigAccessLevel | None = None @override def build_row(self) -> AppConfigAllowListRow: rank = self.rank if self.rank is not None else self.scope_type.default_rank() + read_access = ( + self.read_access + if self.read_access is not None + else self.scope_type.default_read_access() + ) + write_access = ( + self.write_access + if self.write_access is not None + else self.scope_type.default_write_access() + ) return AppConfigAllowListRow( config_name=self.config_name, scope_type=self.scope_type, rank=rank, + read_access=read_access, + write_access=write_access, ) diff --git a/src/ai/backend/manager/repositories/app_config_allow_list/db_source/db_source.py b/src/ai/backend/manager/repositories/app_config_allow_list/db_source/db_source.py index 0e763d13a2a..b00bfd95b1c 100644 --- a/src/ai/backend/manager/repositories/app_config_allow_list/db_source/db_source.py +++ b/src/ai/backend/manager/repositories/app_config_allow_list/db_source/db_source.py @@ -9,6 +9,8 @@ import sqlalchemy as sa +from ai.backend.common.data.app_config.types import AppConfigScopeType +from ai.backend.common.data.filter_specs import StringMatchSpec from ai.backend.common.exception import BackendAIError from ai.backend.common.identifier.app_config_allow_list import AppConfigAllowListID from ai.backend.common.metrics.metric import DomainType, LayerType @@ -20,10 +22,14 @@ AppConfigAllowListSearchResult, ) from ai.backend.manager.errors.app_config import AppConfigAllowListNotFound +from ai.backend.manager.models.app_config_allow_list.conditions import ( + AppConfigAllowListConditions, +) from ai.backend.manager.models.app_config_allow_list.row import AppConfigAllowListRow from ai.backend.manager.repositories.base import ( BatchQuerier, Creator, + OffsetPagination, Purger, Querier, Updater, @@ -94,6 +100,30 @@ async def purge(self, purger: Purger[AppConfigAllowListRow]) -> AppConfigAllowLi raise AppConfigAllowListNotFound("App config allow-list entry not found") return result.row.to_data() + @app_config_allow_list_db_source_resilience.apply() + async def by_config_and_scope( + self, config_name: str, scope_type: AppConfigScopeType + ) -> AppConfigAllowListData | None: + """The allow-list entry for the unique ``(config_name, scope_type)`` pair, or ``None``. + + Used by write/read authorization to read the layer's access tiers. + """ + querier = BatchQuerier( + pagination=OffsetPagination(limit=1, offset=0), + conditions=[ + AppConfigAllowListConditions.by_config_name_equals( + StringMatchSpec(config_name, case_insensitive=False, negated=False) + ), + AppConfigAllowListConditions.by_scope_type_equals(scope_type), + ], + ) + async with self._ops.read_ops() as r: + result = await r.batch_query_in_global(sa.select(AppConfigAllowListRow), querier) + if not result.rows: + return None + data: AppConfigAllowListData = result.rows[0].AppConfigAllowListRow.to_data() + return data + @app_config_allow_list_db_source_resilience.apply() async def search(self, querier: BatchQuerier) -> AppConfigAllowListSearchResult: async with self._ops.read_ops() as r: diff --git a/src/ai/backend/manager/repositories/app_config_allow_list/repository.py b/src/ai/backend/manager/repositories/app_config_allow_list/repository.py index 9d2a53b75ae..f6bc6f84944 100644 --- a/src/ai/backend/manager/repositories/app_config_allow_list/repository.py +++ b/src/ai/backend/manager/repositories/app_config_allow_list/repository.py @@ -1,5 +1,6 @@ from __future__ import annotations +from ai.backend.common.data.app_config.types import AppConfigScopeType from ai.backend.common.exception import BackendAIError from ai.backend.common.identifier.app_config_allow_list import AppConfigAllowListID from ai.backend.common.metrics.metric import DomainType, LayerType @@ -63,6 +64,12 @@ async def create( async def get_by_id(self, allow_list_id: AppConfigAllowListID) -> AppConfigAllowListData: return await self._db_source.get_by_id(allow_list_id) + @app_config_allow_list_repository_resilience.apply() + async def by_config_and_scope( + self, config_name: str, scope_type: AppConfigScopeType + ) -> AppConfigAllowListData | None: + return await self._db_source.by_config_and_scope(config_name, scope_type) + @app_config_allow_list_repository_resilience.apply() async def search(self, querier: BatchQuerier) -> AppConfigAllowListSearchResult: return await self._db_source.search(querier) diff --git a/src/ai/backend/manager/repositories/app_config_allow_list/updaters.py b/src/ai/backend/manager/repositories/app_config_allow_list/updaters.py index 997c6baa29d..14673e84017 100644 --- a/src/ai/backend/manager/repositories/app_config_allow_list/updaters.py +++ b/src/ai/backend/manager/repositories/app_config_allow_list/updaters.py @@ -5,6 +5,7 @@ from dataclasses import dataclass, field from typing import Any, override +from ai.backend.common.data.app_config.types import AppConfigAccessLevel from ai.backend.manager.models.app_config_allow_list.row import AppConfigAllowListRow from ai.backend.manager.repositories.base.updater import UpdaterSpec from ai.backend.manager.types import OptionalState @@ -14,13 +15,20 @@ class AppConfigAllowListUpdaterSpec(UpdaterSpec[AppConfigAllowListRow]): """UpdaterSpec for app config allow-list entries. - Only ``rank`` is updatable — re-ordering the merge is the one post-create - adjustment an entry supports. The identity pair (``config_name``, ``scope_type``) + ``rank`` and the access tiers (``read_access`` / ``write_access``) are updatable — + re-ordering the merge and re-policying who may read/write are the post-create + adjustments an entry supports. The identity pair (``config_name``, ``scope_type``) is immutable: changing it means purging the entry (which cascades to its fragments) and creating a new one. """ rank: OptionalState[int] = field(default_factory=OptionalState[int].nop) + read_access: OptionalState[AppConfigAccessLevel] = field( + default_factory=OptionalState[AppConfigAccessLevel].nop + ) + write_access: OptionalState[AppConfigAccessLevel] = field( + default_factory=OptionalState[AppConfigAccessLevel].nop + ) @property @override @@ -31,4 +39,6 @@ def row_class(self) -> type[AppConfigAllowListRow]: def build_values(self) -> dict[str, Any]: to_update: dict[str, Any] = {} self.rank.update_dict(to_update, "rank") + self.read_access.update_dict(to_update, "read_access") + self.write_access.update_dict(to_update, "write_access") return to_update diff --git a/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py b/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py index 45513ab2872..69d018baad6 100644 --- a/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py +++ b/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py @@ -17,6 +17,7 @@ AppConfigFragmentBulkResult, AppConfigFragmentData, AppConfigFragmentSearchResult, + VisibleFragment, ) from ai.backend.manager.errors.app_config import ( AppConfigFragmentNotFound, @@ -202,24 +203,30 @@ async def scoped_search( @app_config_fragment_db_source_resilience.apply() async def list_visible_fragments_bulk( - self, config_names: list[str], scope: AppConfigScopeArguments - ) -> list[AppConfigFragmentData]: + self, config_names: list[str], scope: AppConfigScopeArguments | None = None + ) -> list[VisibleFragment]: """Visible fragments for several ``config_names`` at once, in a single query. - Selects the requested names AND any one of the principal's visible scopes (public, - its domain, or its own user). The scope filter is name-independent, so it is a single - OR group AND-combined with the name membership. Merge priority (``rank``) lives on the - joined allow-list entry; the result is always ordered by ascending ``rank`` so the - caller can group by name (each name's subset stays rank-ordered) and deep-merge each - name's fragments in order. + Selects the requested names AND any one of the visible scopes. ``public`` always + contributes; a ``scope`` (principal) additionally admits its own domain and user + overlay. With ``scope=None`` — the anonymous, pre-login read — only ``public``-scope + documents contribute, so the caller sees the shared baseline with no principal overlay. + + The scope filter is name-independent, so it is a single OR group AND-combined with the + name membership. Merge priority (``rank``) lives on the joined allow-list entry; the + result is always ordered by ascending ``rank`` so the caller can group by name (each + name's subset stays rank-ordered) and deep-merge each name's fragments in order. Each + fragment is paired with its layer's ``read_access`` tier (also from the joined entry) + so the service can drop layers the reader may not see before merging. """ if not config_names: return [] - scope_visibility = [ - AppConfigFragmentConditions.by_public_visibility(), - AppConfigFragmentConditions.by_domain_visibility(str(scope.domain_id)), - AppConfigFragmentConditions.by_user_visibility(str(scope.user_id)), - ] + scope_visibility = [AppConfigFragmentConditions.by_public_visibility()] + if scope is not None: + scope_visibility += [ + AppConfigFragmentConditions.by_domain_visibility(str(scope.domain_id)), + AppConfigFragmentConditions.by_user_visibility(str(scope.user_id)), + ] querier = BatchQuerier( pagination=NoPagination(), conditions=[ @@ -229,8 +236,9 @@ async def list_visible_fragments_bulk( orders=[AppConfigAllowListRow.rank.asc()], ) # Join each fragment to its allow-list entry (indexed ``(config_name, scope_type)`` FK - # pair), which carries the merge ``rank`` the result is ordered by. - selector = sa.select(AppConfigFragmentRow).join( + # pair), which carries the merge ``rank`` the result is ordered by and the + # ``read_access`` tier the reader is checked against. + selector = sa.select(AppConfigFragmentRow, AppConfigAllowListRow.read_access).join( AppConfigAllowListRow, sa.and_( AppConfigAllowListRow.config_name == AppConfigFragmentRow.config_name, @@ -239,4 +247,10 @@ async def list_visible_fragments_bulk( ) async with self._ops.read_ops() as r: result = await r.batch_query_in_global(selector, querier) - return [row.AppConfigFragmentRow.to_data() for row in result.rows] + return [ + VisibleFragment( + data=row.AppConfigFragmentRow.to_data(), + read_access=row.read_access, + ) + for row in result.rows + ] diff --git a/src/ai/backend/manager/repositories/app_config_fragment/repository.py b/src/ai/backend/manager/repositories/app_config_fragment/repository.py index 43334e915c5..e509d9f3bf2 100644 --- a/src/ai/backend/manager/repositories/app_config_fragment/repository.py +++ b/src/ai/backend/manager/repositories/app_config_fragment/repository.py @@ -12,6 +12,7 @@ AppConfigFragmentBulkResult, AppConfigFragmentData, AppConfigFragmentSearchResult, + VisibleFragment, ) from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow from ai.backend.manager.models.scopes import SearchScope @@ -109,6 +110,6 @@ async def bulk_purge( @app_config_fragment_repository_resilience.apply() async def list_visible_fragments_bulk( - self, config_names: list[str], scope: AppConfigScopeArguments - ) -> list[AppConfigFragmentData]: + self, config_names: list[str], scope: AppConfigScopeArguments | None = None + ) -> list[VisibleFragment]: return await self._db_source.list_visible_fragments_bulk(config_names, scope) diff --git a/src/ai/backend/manager/repositories/repositories.py b/src/ai/backend/manager/repositories/repositories.py index fed6ba2df99..f388c0fdbe5 100644 --- a/src/ai/backend/manager/repositories/repositories.py +++ b/src/ai/backend/manager/repositories/repositories.py @@ -8,6 +8,9 @@ from ai.backend.manager.repositories.app_config_definition.repositories import ( AppConfigDefinitionRepositories, ) +from ai.backend.manager.repositories.app_config_fragment.repositories import ( + AppConfigFragmentRepositories, +) from ai.backend.manager.repositories.artifact.repositories import ArtifactRepositories from ai.backend.manager.repositories.artifact_registry.repositories import ( ArtifactRegistryRepositories, @@ -94,6 +97,7 @@ class Repositories: agent: AgentRepositories app_config_allow_list: AppConfigAllowListRepositories app_config_definition: AppConfigDefinitionRepositories + app_config_fragment: AppConfigFragmentRepositories auth: AuthRepositories container_registry: ContainerRegistryRepositories deployment: DeploymentRepositories @@ -148,6 +152,7 @@ def create(cls, args: RepositoryArgs) -> Self: agent_repositories = AgentRepositories.create(args) app_config_allow_list_repositories = AppConfigAllowListRepositories.create(args) app_config_definition_repositories = AppConfigDefinitionRepositories.create(args) + app_config_fragment_repositories = AppConfigFragmentRepositories.create(args) auth_repositories = AuthRepositories.create(args) container_registry_repositories = ContainerRegistryRepositories.create(args) deployment_repositories = DeploymentRepositories.create(args) @@ -203,6 +208,7 @@ def create(cls, args: RepositoryArgs) -> Self: agent=agent_repositories, app_config_allow_list=app_config_allow_list_repositories, app_config_definition=app_config_definition_repositories, + app_config_fragment=app_config_fragment_repositories, auth=auth_repositories, container_registry=container_registry_repositories, deployment=deployment_repositories, diff --git a/src/ai/backend/manager/services/app_config/__init__.py b/src/ai/backend/manager/services/app_config/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/manager/services/app_config/actions/__init__.py b/src/ai/backend/manager/services/app_config/actions/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/manager/services/app_config/actions/base.py b/src/ai/backend/manager/services/app_config/actions/base.py new file mode 100644 index 00000000000..d134ddd3700 --- /dev/null +++ b/src/ai/backend/manager/services/app_config/actions/base.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import override + +from ai.backend.common.data.permission.types import EntityType +from ai.backend.manager.actions.action.scope import BaseScopeAction, BaseScopeActionResult + + +class AppConfigScopeAction(BaseScopeAction): + """Base for scope-level merged app config actions (resolve).""" + + @override + @classmethod + def entity_type(cls) -> EntityType: + return EntityType.APP_CONFIG + + +class AppConfigScopeActionResult(BaseScopeActionResult): + pass diff --git a/src/ai/backend/manager/services/app_config/actions/resolve.py b/src/ai/backend/manager/services/app_config/actions/resolve.py new file mode 100644 index 00000000000..6ca1c871b1a --- /dev/null +++ b/src/ai/backend/manager/services/app_config/actions/resolve.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import override + +from ai.backend.common.data.permission.types import RBACElementType, ScopeType +from ai.backend.common.data.user.types import UserData +from ai.backend.common.identifier.user import UserID +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.data.app_config.types import AppConfigData +from ai.backend.manager.data.permission.types import RBACElementRef +from ai.backend.manager.repositories.app_config_fragment.types import AppConfigScopeArguments +from ai.backend.manager.services.app_config.actions.base import ( + AppConfigScopeAction, + AppConfigScopeActionResult, +) + + +@dataclass +class ResolveAppConfigAction(AppConfigScopeAction): + """Resolve the merged ``AppConfig`` for one ``config_name``. + + ``scope`` carries the resolving principal ``(user, domain)``. When it is ``None`` — the + anonymous, pre-login read — only ``public``-scope fragments contribute and the action is + a ``GLOBAL`` read attributable to no user. ``requester`` is the caller checked against + each layer's ``read_access`` tier (``None`` for the anonymous read → public layers only). + """ + + config_name: str + scope: AppConfigScopeArguments | None = None + requester: UserData | None = None + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.GET + + @override + def scope_type(self) -> ScopeType: + return ScopeType.USER if self.scope is not None else ScopeType.GLOBAL + + @override + def scope_id(self) -> str: + return str(self.scope.user_id) if self.scope is not None else "" + + @override + def target_element(self) -> RBACElementRef: + return RBACElementRef(RBACElementType.APP_CONFIG, "") + + +@dataclass +class ResolveAppConfigActionResult(AppConfigScopeActionResult): + app_config: AppConfigData + _user_id: UserID | None + + @override + def scope_type(self) -> ScopeType: + return ScopeType.USER if self._user_id is not None else ScopeType.GLOBAL + + @override + def scope_id(self) -> str: + return str(self._user_id) if self._user_id is not None else "" diff --git a/src/ai/backend/manager/services/app_config/actions/resolve_bulk.py b/src/ai/backend/manager/services/app_config/actions/resolve_bulk.py new file mode 100644 index 00000000000..da7393e08e3 --- /dev/null +++ b/src/ai/backend/manager/services/app_config/actions/resolve_bulk.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import override + +from ai.backend.common.data.permission.types import RBACElementType, ScopeType +from ai.backend.common.data.user.types import UserData +from ai.backend.common.identifier.user import UserID +from ai.backend.manager.actions.types import ActionOperationType +from ai.backend.manager.data.app_config.types import AppConfigData +from ai.backend.manager.data.permission.types import RBACElementRef +from ai.backend.manager.repositories.app_config_fragment.types import AppConfigScopeArguments +from ai.backend.manager.services.app_config.actions.base import ( + AppConfigScopeAction, + AppConfigScopeActionResult, +) + + +@dataclass +class ResolveBulkAppConfigAction(AppConfigScopeAction): + """Resolve several merged ``AppConfig``s for one principal at once. + + ``scope`` carries the resolving principal ``(user, domain)``. When it is ``None`` — the + anonymous, pre-login read — only ``public``-scope fragments contribute and the action is + a ``GLOBAL`` read attributable to no user. ``requester`` is the caller checked against + each layer's ``read_access`` tier (``None`` for the anonymous read → public layers only). + """ + + config_names: list[str] + scope: AppConfigScopeArguments | None = None + requester: UserData | None = None + + @override + @classmethod + def operation_type(cls) -> ActionOperationType: + return ActionOperationType.GET + + @override + def scope_type(self) -> ScopeType: + return ScopeType.USER if self.scope is not None else ScopeType.GLOBAL + + @override + def scope_id(self) -> str: + return str(self.scope.user_id) if self.scope is not None else "" + + @override + def target_element(self) -> RBACElementRef: + return RBACElementRef(RBACElementType.APP_CONFIG, "") + + +@dataclass +class ResolveBulkAppConfigActionResult(AppConfigScopeActionResult): + app_configs: list[AppConfigData] + _user_id: UserID | None + + @override + def scope_type(self) -> ScopeType: + return ScopeType.USER if self._user_id is not None else ScopeType.GLOBAL + + @override + def scope_id(self) -> str: + return str(self._user_id) if self._user_id is not None else "" diff --git a/src/ai/backend/manager/services/app_config/processors.py b/src/ai/backend/manager/services/app_config/processors.py new file mode 100644 index 00000000000..5ce9c6e1b76 --- /dev/null +++ b/src/ai/backend/manager/services/app_config/processors.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from typing import override + +from ai.backend.manager.actions.monitors.monitor import ActionMonitor +from ai.backend.manager.actions.processor.scope import ScopeActionProcessor +from ai.backend.manager.actions.types import AbstractProcessorPackage, ActionSpec +from ai.backend.manager.services.app_config.actions.resolve import ( + ResolveAppConfigAction, + ResolveAppConfigActionResult, +) +from ai.backend.manager.services.app_config.actions.resolve_bulk import ( + ResolveBulkAppConfigAction, + ResolveBulkAppConfigActionResult, +) +from ai.backend.manager.services.app_config.service import AppConfigService + + +class AppConfigProcessors(AbstractProcessorPackage): + resolve_app_config: ScopeActionProcessor[ResolveAppConfigAction, ResolveAppConfigActionResult] + resolve_app_config_bulk: ScopeActionProcessor[ + ResolveBulkAppConfigAction, ResolveBulkAppConfigActionResult + ] + + def __init__( + self, + service: AppConfigService, + action_monitors: list[ActionMonitor], + ) -> None: + self.resolve_app_config = ScopeActionProcessor(service.resolve_app_config, action_monitors) + self.resolve_app_config_bulk = ScopeActionProcessor( + service.resolve_app_config_bulk, action_monitors + ) + + @override + def supported_actions(self) -> list[ActionSpec]: + return [ + ResolveAppConfigAction.spec(), + ResolveBulkAppConfigAction.spec(), + ] diff --git a/src/ai/backend/manager/services/app_config/service.py b/src/ai/backend/manager/services/app_config/service.py new file mode 100644 index 00000000000..540e8126b79 --- /dev/null +++ b/src/ai/backend/manager/services/app_config/service.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + +from ai.backend.manager.data.app_config.types import AppConfigData +from ai.backend.manager.data.app_config_fragment.types import AppConfigFragmentData +from ai.backend.manager.repositories.app_config_fragment.repository import ( + AppConfigFragmentRepository, +) +from ai.backend.manager.services.app_config.actions.resolve import ( + ResolveAppConfigAction, + ResolveAppConfigActionResult, +) +from ai.backend.manager.services.app_config.actions.resolve_bulk import ( + ResolveBulkAppConfigAction, + ResolveBulkAppConfigActionResult, +) + +__all__ = ("AppConfigService",) + + +def _recursive_override(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict[str, Any]: + """Merge ``override`` onto ``base``: nested dicts recurse; everything else replaces whole. + + A higher-rank value replaces the lower one entirely — lists included (no element-by-index + blending), and an explicit ``None`` erases the inherited value (unset). Unlike + :func:`ai.backend.common.utils.deep_merge`, which blends lists by index and ignores ``None``. + """ + result: dict[str, Any] = dict(base) + for key, override_value in override.items(): + base_value = result.get(key) + if isinstance(base_value, Mapping) and isinstance(override_value, Mapping): + result[key] = _recursive_override(base_value, override_value) + else: + result[key] = override_value + return result + + +def _merge_configs(fragments: Sequence[AppConfigFragmentData]) -> dict[str, Any] | None: + """Deep-merge fragment configs in ascending ``rank`` order; ``None`` when none contribute. + + Nested dicts recurse; lists and scalars are replaced wholesale by the higher-rank + fragment (a user's list overrides the lower scope's entirely — not by index). ``None`` + marks a config name that is defined but left unconfigured for this scope, distinct from + a fragment that merges to an empty ``{}``. + """ + if not fragments: + return None + merged: dict[str, Any] = {} + for fragment in fragments: + merged = _recursive_override(merged, fragment.config) + return merged + + +class AppConfigService: + """Read-side service for the merged ``AppConfig`` view.""" + + _fragment_repository: AppConfigFragmentRepository + + def __init__(self, fragment_repository: AppConfigFragmentRepository) -> None: + self._fragment_repository = fragment_repository + + async def resolve_app_config( + self, action: ResolveAppConfigAction + ) -> ResolveAppConfigActionResult: + """Resolve the merged ``AppConfig`` for ``config_name``. + + With a principal ``scope`` the merge overlays the caller's domain and user fragments + on top of ``public``; with ``scope=None`` (anonymous, pre-login) only ``public`` + fragments contribute. Each visible layer is additionally gated by its ``read_access`` + tier — a layer the ``requester`` may not read is dropped before merging. + """ + visible = await self._fragment_repository.list_visible_fragments_bulk( + [action.config_name], action.scope + ) + fragments = [ + vf.data + for vf in visible + if vf.read_access.is_satisfied_by( + action.requester, vf.data.scope_type, vf.data.scope_id + ) + ] + app_config = AppConfigData( + config_name=action.config_name, + fragments=fragments, + merged_config=_merge_configs(fragments), + ) + user_id = action.scope.user_id if action.scope is not None else None + return ResolveAppConfigActionResult(app_config=app_config, _user_id=user_id) + + async def resolve_app_config_bulk( + self, action: ResolveBulkAppConfigAction + ) -> ResolveBulkAppConfigActionResult: + """Resolve several merged ``AppConfig``s for one principal in a single query. + + One entry per requested ``config_name``, in request order — a name repeated in the + input is repeated in the output (each position resolves independently, never + collapsed). With ``scope=None`` (anonymous, pre-login) only ``public`` fragments + contribute to every entry. Each visible layer is additionally gated by its + ``read_access`` tier — a layer the ``requester`` may not read is dropped before merging. + """ + visible = await self._fragment_repository.list_visible_fragments_bulk( + action.config_names, action.scope + ) + fragments = [ + vf.data + for vf in visible + if vf.read_access.is_satisfied_by( + action.requester, vf.data.scope_type, vf.data.scope_id + ) + ] + app_configs: list[AppConfigData] = [] + for config_name in action.config_names: + subset = [fragment for fragment in fragments if fragment.config_name == config_name] + app_configs.append( + AppConfigData( + config_name=config_name, + fragments=subset, + merged_config=_merge_configs(subset), + ) + ) + user_id = action.scope.user_id if action.scope is not None else None + return ResolveBulkAppConfigActionResult(app_configs=app_configs, _user_id=user_id) diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/bulk_create.py b/src/ai/backend/manager/services/app_config_fragment/actions/bulk_create.py index 86f011241f1..0a4109dd26b 100644 --- a/src/ai/backend/manager/services/app_config_fragment/actions/bulk_create.py +++ b/src/ai/backend/manager/services/app_config_fragment/actions/bulk_create.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from typing import override +from ai.backend.common.data.user.types import UserData from ai.backend.manager.actions.types import ActionOperationType from ai.backend.manager.repositories.app_config_fragment.creators import ( AppConfigFragmentCreatorSpec, @@ -20,6 +21,7 @@ class BulkCreateAppConfigFragmentAction(AppConfigFragmentBulkAction): """Create many fragments with per-item partial success; the FK to the allow-list gates each write.""" creator_specs: Sequence[AppConfigFragmentCreatorSpec] + requester: UserData | None = None @override @classmethod diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/bulk_purge.py b/src/ai/backend/manager/services/app_config_fragment/actions/bulk_purge.py index 12349e3f2d8..4898dc5a7c6 100644 --- a/src/ai/backend/manager/services/app_config_fragment/actions/bulk_purge.py +++ b/src/ai/backend/manager/services/app_config_fragment/actions/bulk_purge.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from typing import cast, override +from ai.backend.common.data.user.types import UserData from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID from ai.backend.manager.actions.types import ActionOperationType from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow @@ -20,6 +21,7 @@ class BulkPurgeAppConfigFragmentAction(AppConfigFragmentBulkAction): """Purge many fragments with per-item partial success (no gate — purging the allow-list entry itself cascades to its fragments separately).""" purgers: Sequence[Purger[AppConfigFragmentRow]] + requester: UserData | None = None @override @classmethod diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/bulk_update.py b/src/ai/backend/manager/services/app_config_fragment/actions/bulk_update.py index 6610ee75acb..2ede48b9379 100644 --- a/src/ai/backend/manager/services/app_config_fragment/actions/bulk_update.py +++ b/src/ai/backend/manager/services/app_config_fragment/actions/bulk_update.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from typing import cast, override +from ai.backend.common.data.user.types import UserData from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID from ai.backend.manager.actions.types import ActionOperationType from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow @@ -20,6 +21,7 @@ class BulkUpdateAppConfigFragmentAction(AppConfigFragmentBulkAction): """Update many fragments' ``config`` with per-item partial success (no gate — an existing fragment is always writable at its own scope).""" updaters: Sequence[Updater[AppConfigFragmentRow]] + requester: UserData | None = None @override @classmethod diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/create.py b/src/ai/backend/manager/services/app_config_fragment/actions/create.py index 990ce669816..f5cdfb7fed5 100644 --- a/src/ai/backend/manager/services/app_config_fragment/actions/create.py +++ b/src/ai/backend/manager/services/app_config_fragment/actions/create.py @@ -4,6 +4,7 @@ from typing import override from ai.backend.common.data.permission.types import RBACElementType, ScopeType +from ai.backend.common.data.user.types import UserData from ai.backend.manager.actions.types import ActionOperationType from ai.backend.manager.data.app_config_fragment.types import ( AppConfigFragmentData, @@ -28,6 +29,7 @@ class CreateAppConfigFragmentAction(AppConfigFragmentScopeAction): """ creator_spec: AppConfigFragmentCreatorSpec + requester: UserData | None = None @override @classmethod diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/purge.py b/src/ai/backend/manager/services/app_config_fragment/actions/purge.py index f837ab2a9a2..9ad0874be46 100644 --- a/src/ai/backend/manager/services/app_config_fragment/actions/purge.py +++ b/src/ai/backend/manager/services/app_config_fragment/actions/purge.py @@ -4,6 +4,7 @@ from typing import override from ai.backend.common.data.permission.types import RBACElementType +from ai.backend.common.data.user.types import UserData from ai.backend.manager.actions.types import ActionOperationType from ai.backend.manager.data.app_config_fragment.types import AppConfigFragmentData from ai.backend.manager.data.permission.types import RBACElementRef @@ -26,6 +27,7 @@ class PurgeAppConfigFragmentAction(AppConfigFragmentSingleEntityAction): """ purger: Purger[AppConfigFragmentRow] + requester: UserData | None = None @override @classmethod diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/scoped_search.py b/src/ai/backend/manager/services/app_config_fragment/actions/scoped_search.py index 0dfe6572e78..1ca8f2f68a2 100644 --- a/src/ai/backend/manager/services/app_config_fragment/actions/scoped_search.py +++ b/src/ai/backend/manager/services/app_config_fragment/actions/scoped_search.py @@ -7,6 +7,7 @@ from typing import override from ai.backend.common.data.permission.types import EntityType, RBACElementType +from ai.backend.common.data.user.types import UserData from ai.backend.common.identifier.domain import DomainID from ai.backend.common.identifier.user import UserID from ai.backend.manager.actions.action.bulk import BaseBulkAction, BaseBulkActionResult @@ -54,10 +55,15 @@ def to_search_scope(self) -> SearchScope: @dataclass class ScopedSearchAppConfigFragmentAction(BaseBulkAction[SearchableActionTarget]): - """Scoped path: search the fragments under the given domain/user scope targets.""" + """Scoped path: search the fragments under the given domain/user scope targets. + + ``requester`` is the caller each returned fragment is gated against by its layer's + ``read_access`` tier (``None`` = anonymous → ``public``-readable layers only). + """ items: list[SearchableActionTarget] querier: BatchQuerier + requester: UserData | None = None @override def entity_id(self) -> str | None: diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/update.py b/src/ai/backend/manager/services/app_config_fragment/actions/update.py index f2b252d4757..6649b09a8c5 100644 --- a/src/ai/backend/manager/services/app_config_fragment/actions/update.py +++ b/src/ai/backend/manager/services/app_config_fragment/actions/update.py @@ -4,6 +4,7 @@ from typing import override from ai.backend.common.data.permission.types import RBACElementType +from ai.backend.common.data.user.types import UserData from ai.backend.manager.actions.types import ActionOperationType from ai.backend.manager.data.app_config_fragment.types import AppConfigFragmentData from ai.backend.manager.data.permission.types import RBACElementRef @@ -26,6 +27,7 @@ class UpdateAppConfigFragmentAction(AppConfigFragmentSingleEntityAction): """ updater: Updater[AppConfigFragmentRow] + requester: UserData | None = None @override @classmethod diff --git a/src/ai/backend/manager/services/app_config_fragment/service.py b/src/ai/backend/manager/services/app_config_fragment/service.py index 0c0ce5afbbf..b59acff23e5 100644 --- a/src/ai/backend/manager/services/app_config_fragment/service.py +++ b/src/ai/backend/manager/services/app_config_fragment/service.py @@ -1,9 +1,30 @@ from __future__ import annotations +from typing import cast + +from ai.backend.common.data.app_config.types import AppConfigAccessLevel, AppConfigScopeType +from ai.backend.common.data.user.types import UserData +from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID +from ai.backend.manager.data.app_config_fragment.types import ( + AppConfigFragmentBulkItemError, + AppConfigFragmentBulkResult, + AppConfigFragmentData, +) +from ai.backend.manager.errors.app_config import ( + AppConfigFragmentNotFound, + AppConfigFragmentWriteNotAllowed, +) +from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow +from ai.backend.manager.repositories.app_config_allow_list.repository import ( + AppConfigAllowListRepository, +) +from ai.backend.manager.repositories.app_config_fragment.creators import ( + AppConfigFragmentCreatorSpec, +) from ai.backend.manager.repositories.app_config_fragment.repository import ( AppConfigFragmentRepository, ) -from ai.backend.manager.repositories.base import BulkCreator, Creator +from ai.backend.manager.repositories.base import BulkCreator, Creator, Purger, Updater from ai.backend.manager.services.app_config_fragment.actions.admin_search import ( AdminSearchAppConfigFragmentAction, AdminSearchAppConfigFragmentActionResult, @@ -47,22 +68,56 @@ class AppConfigFragmentService: """Write/read paths for app config fragments (not admin-only). - ``create`` is gated by the fragment's FK to ``app_config_allow_list``: an insert with - no allow-list row for its ``(config_name, scope_type)`` is rejected as a write-not- - allowed error. An allow-list row requires a registered ``config_name`` (FK), so this - also enforces registration. An allow-listed user may therefore manage their own - ``user``-scope fragment without admin privileges. + Writes are authorized against the ``write_access`` tier of the target layer's + ``app_config_allow_list`` entry — AppConfig's standalone authorization (there is no + RBAC validator). The allow-list row's existence only *registers* the layer; who may + write is decided by ``write_access`` (e.g. a user may manage their own ``user``-scope + fragment; ``public`` / ``domain`` default to admin-only). """ _repository: AppConfigFragmentRepository + _allow_list_repository: AppConfigAllowListRepository - def __init__(self, repository: AppConfigFragmentRepository) -> None: + def __init__( + self, + repository: AppConfigFragmentRepository, + allow_list_repository: AppConfigAllowListRepository, + ) -> None: self._repository = repository + self._allow_list_repository = allow_list_repository + + async def _authorize_write( + self, + requester: UserData | None, + config_name: str, + scope_type: AppConfigScopeType, + scope_id: str, + ) -> None: + """Raise ``AppConfigFragmentWriteNotAllowed`` unless ``requester`` may write the layer. + + The target layer's allow-list entry carries the ``write_access`` tier; a missing + entry means the layer is not registered (nothing is writable there). + """ + entry = await self._allow_list_repository.by_config_and_scope(config_name, scope_type) + if entry is None: + raise AppConfigFragmentWriteNotAllowed( + f"Writing app config {config_name!r} at scope {scope_type.value!r} is not " + "allowed (no allow-list entry)." + ) + if not entry.write_access.is_satisfied_by(requester, scope_type, scope_id): + raise AppConfigFragmentWriteNotAllowed( + f"Writing app config {config_name!r} at scope {scope_type.value!r} is not " + "allowed for this caller." + ) async def create( self, action: CreateAppConfigFragmentAction ) -> CreateAppConfigFragmentActionResult: - data = await self._repository.create(Creator(spec=action.creator_spec)) + spec = action.creator_spec + await self._authorize_write( + action.requester, spec.config_name, spec.scope_type, spec.scope_id + ) + data = await self._repository.create(Creator(spec=spec)) return CreateAppConfigFragmentActionResult(fragment=data) async def get(self, action: GetAppConfigFragmentAction) -> GetAppConfigFragmentActionResult: @@ -80,14 +135,40 @@ async def admin_search( has_previous_page=result.has_previous_page, ) + async def _filter_readable( + self, requester: UserData | None, items: list[AppConfigFragmentData] + ) -> list[AppConfigFragmentData]: + """Drop fragments whose layer ``read_access`` the ``requester`` does not satisfy. + + The tier per ``(config_name, scope_type)`` is looked up once and cached. Note this + gates the returned page only; ``total_count`` still reflects the visible-scope match + count before the read_access filter (paginated counts are not re-derived here). + """ + cache: dict[tuple[str, AppConfigScopeType], AppConfigAccessLevel | None] = {} + readable: list[AppConfigFragmentData] = [] + for item in items: + key = (item.config_name, item.scope_type) + if key not in cache: + entry = await self._allow_list_repository.by_config_and_scope( + item.config_name, item.scope_type + ) + cache[key] = entry.read_access if entry is not None else None + read_access = cache[key] + if read_access is not None and read_access.is_satisfied_by( + requester, item.scope_type, item.scope_id + ): + readable.append(item) + return readable + async def scoped_search( self, action: ScopedSearchAppConfigFragmentAction ) -> ScopedSearchAppConfigFragmentActionResult: targets = list(action.targets()) scopes = [t.to_search_scope() for t in targets] result = await self._repository.scoped_search(action.querier, scopes) + readable = await self._filter_readable(action.requester, result.items) return ScopedSearchAppConfigFragmentActionResult( - data=result.items, + data=readable, total_count=result.total_count, has_next_page=result.has_next_page, has_previous_page=result.has_previous_page, @@ -97,35 +178,121 @@ async def scoped_search( async def update( self, action: UpdateAppConfigFragmentAction ) -> UpdateAppConfigFragmentActionResult: + fragment_id = cast(AppConfigFragmentID, action.updater.pk_value) + existing = await self._repository.get_by_id(fragment_id) + await self._authorize_write( + action.requester, existing.config_name, existing.scope_type, existing.scope_id + ) data = await self._repository.update(action.updater) return UpdateAppConfigFragmentActionResult(fragment=data) async def purge( self, action: PurgeAppConfigFragmentAction ) -> PurgeAppConfigFragmentActionResult: + fragment_id = cast(AppConfigFragmentID, action.purger.pk_value) + existing = await self._repository.get_by_id(fragment_id) + await self._authorize_write( + action.requester, existing.config_name, existing.scope_type, existing.scope_id + ) data = await self._repository.purge(action.purger) return PurgeAppConfigFragmentActionResult(fragment=data) + @staticmethod + def _remap_failures( + prefail: list[AppConfigFragmentBulkItemError], + repo_failed: list[AppConfigFragmentBulkItemError], + original_indices: list[int], + ) -> list[AppConfigFragmentBulkItemError]: + """Merge pre-authz failures with repo failures, remapping the latter to batch indices.""" + merged = list(prefail) + merged.extend( + AppConfigFragmentBulkItemError(index=original_indices[e.index], message=e.message) + for e in repo_failed + ) + merged.sort(key=lambda e: e.index) + return merged + async def bulk_create( self, action: BulkCreateAppConfigFragmentAction ) -> BulkCreateAppConfigFragmentActionResult: - result = await self._repository.bulk_create(BulkCreator(specs=action.creator_specs)) + authorized: list[AppConfigFragmentCreatorSpec] = [] + original_indices: list[int] = [] + prefail: list[AppConfigFragmentBulkItemError] = [] + for index, spec in enumerate(action.creator_specs): + try: + await self._authorize_write( + action.requester, spec.config_name, spec.scope_type, spec.scope_id + ) + except AppConfigFragmentWriteNotAllowed as e: + prefail.append(AppConfigFragmentBulkItemError(index=index, message=str(e))) + continue + authorized.append(spec) + original_indices.append(index) + if authorized: + result = await self._repository.bulk_create(BulkCreator(specs=authorized)) + else: + result = AppConfigFragmentBulkResult(succeeded=[], failed=[]) return BulkCreateAppConfigFragmentActionResult( - succeeded=result.succeeded, failed=result.failed + succeeded=result.succeeded, + failed=self._remap_failures(prefail, result.failed, original_indices), ) async def bulk_update( self, action: BulkUpdateAppConfigFragmentAction ) -> BulkUpdateAppConfigFragmentActionResult: - result = await self._repository.bulk_update(action.updaters) + authorized: list[Updater[AppConfigFragmentRow]] = [] + original_indices: list[int] = [] + prefail: list[AppConfigFragmentBulkItemError] = [] + for index, updater in enumerate(action.updaters): + fragment_id = cast(AppConfigFragmentID, updater.pk_value) + try: + existing = await self._repository.get_by_id(fragment_id) + await self._authorize_write( + action.requester, + existing.config_name, + existing.scope_type, + existing.scope_id, + ) + except (AppConfigFragmentNotFound, AppConfigFragmentWriteNotAllowed) as e: + prefail.append(AppConfigFragmentBulkItemError(index=index, message=str(e))) + continue + authorized.append(updater) + original_indices.append(index) + if authorized: + result = await self._repository.bulk_update(authorized) + else: + result = AppConfigFragmentBulkResult(succeeded=[], failed=[]) return BulkUpdateAppConfigFragmentActionResult( - succeeded=result.succeeded, failed=result.failed + succeeded=result.succeeded, + failed=self._remap_failures(prefail, result.failed, original_indices), ) async def bulk_purge( self, action: BulkPurgeAppConfigFragmentAction ) -> BulkPurgeAppConfigFragmentActionResult: - result = await self._repository.bulk_purge(action.purgers) + authorized: list[Purger[AppConfigFragmentRow]] = [] + original_indices: list[int] = [] + prefail: list[AppConfigFragmentBulkItemError] = [] + for index, purger in enumerate(action.purgers): + fragment_id = cast(AppConfigFragmentID, purger.pk_value) + try: + existing = await self._repository.get_by_id(fragment_id) + await self._authorize_write( + action.requester, + existing.config_name, + existing.scope_type, + existing.scope_id, + ) + except (AppConfigFragmentNotFound, AppConfigFragmentWriteNotAllowed) as e: + prefail.append(AppConfigFragmentBulkItemError(index=index, message=str(e))) + continue + authorized.append(purger) + original_indices.append(index) + if authorized: + result = await self._repository.bulk_purge(authorized) + else: + result = AppConfigFragmentBulkResult(succeeded=[], failed=[]) return BulkPurgeAppConfigFragmentActionResult( - succeeded=result.succeeded, failed=result.failed + succeeded=result.succeeded, + failed=self._remap_failures(prefail, result.failed, original_indices), ) diff --git a/src/ai/backend/manager/services/factory.py b/src/ai/backend/manager/services/factory.py index 38f52b4dcdc..fff1cac926f 100644 --- a/src/ai/backend/manager/services/factory.py +++ b/src/ai/backend/manager/services/factory.py @@ -6,6 +6,12 @@ ) from ai.backend.manager.services.agent.processors import AgentProcessors from ai.backend.manager.services.agent.service import AgentService +from ai.backend.manager.services.app_config.processors import ( + AppConfigProcessors, +) +from ai.backend.manager.services.app_config.service import ( + AppConfigService, +) from ai.backend.manager.services.app_config_allow_list.processors import ( AppConfigAllowListProcessors, ) @@ -18,6 +24,12 @@ from ai.backend.manager.services.app_config_definition.service import ( AppConfigDefinitionService, ) +from ai.backend.manager.services.app_config_fragment.processors import ( + AppConfigFragmentProcessors, +) +from ai.backend.manager.services.app_config_fragment.service import ( + AppConfigFragmentService, +) from ai.backend.manager.services.artifact.processors import ArtifactProcessors from ai.backend.manager.services.artifact.service import ArtifactService from ai.backend.manager.services.artifact_registry.processors import ArtifactRegistryProcessors @@ -174,9 +186,16 @@ def create_services(args: ServiceArgs) -> Services: args.event_producer, args.agent_cache, ), + app_config=AppConfigService( + fragment_repository=repositories.app_config_fragment.repository, + ), app_config_allow_list=AppConfigAllowListService( repository=repositories.app_config_allow_list.repository, ), + app_config_fragment=AppConfigFragmentService( + repository=repositories.app_config_fragment.repository, + allow_list_repository=repositories.app_config_allow_list.repository, + ), domain=DomainService(repositories.domain.repository), dotfile=DotfileService( repository=repositories.dotfile.repository, @@ -442,9 +461,13 @@ def create_processors( services = create_services(args.service_args) return Processors( agent=AgentProcessors(services.agent, action_monitors, validators), + app_config=AppConfigProcessors(services.app_config, action_monitors), app_config_allow_list=AppConfigAllowListProcessors( services.app_config_allow_list, action_monitors ), + app_config_fragment=AppConfigFragmentProcessors( + services.app_config_fragment, action_monitors + ), domain=DomainProcessors(services.domain, action_monitors, validators), dotfile=DotfileProcessors(services.dotfile, action_monitors, validators), error_log=ErrorLogProcessors(services.error_log, action_monitors, validators), diff --git a/src/ai/backend/manager/services/processors.py b/src/ai/backend/manager/services/processors.py index 0e046773397..821b1de8f34 100644 --- a/src/ai/backend/manager/services/processors.py +++ b/src/ai/backend/manager/services/processors.py @@ -45,6 +45,12 @@ ) from ai.backend.manager.services.agent.processors import AgentProcessors from ai.backend.manager.services.agent.service import AgentService + from ai.backend.manager.services.app_config.processors import ( + AppConfigProcessors, + ) + from ai.backend.manager.services.app_config.service import ( + AppConfigService, + ) from ai.backend.manager.services.app_config_allow_list.processors import ( AppConfigAllowListProcessors, ) @@ -57,6 +63,12 @@ from ai.backend.manager.services.app_config_definition.service import ( AppConfigDefinitionService, ) + from ai.backend.manager.services.app_config_fragment.processors import ( + AppConfigFragmentProcessors, + ) + from ai.backend.manager.services.app_config_fragment.service import ( + AppConfigFragmentService, + ) from ai.backend.manager.services.artifact.processors import ( ArtifactProcessors, ) @@ -375,8 +387,10 @@ class ServiceArgs: @dataclass class Services: agent: AgentService + app_config: AppConfigService app_config_allow_list: AppConfigAllowListService app_config_definition: AppConfigDefinitionService + app_config_fragment: AppConfigFragmentService domain: DomainService dotfile: DotfileService error_log: ErrorLogService @@ -442,8 +456,10 @@ class ProcessorArgs: @dataclass class Processors(AbstractProcessorPackage): agent: AgentProcessors + app_config: AppConfigProcessors app_config_allow_list: AppConfigAllowListProcessors app_config_definition: AppConfigDefinitionProcessors + app_config_fragment: AppConfigFragmentProcessors domain: DomainProcessors dotfile: DotfileProcessors error_log: ErrorLogProcessors @@ -502,8 +518,10 @@ class Processors(AbstractProcessorPackage): def supported_actions(self) -> list[ActionSpec]: return [ *self.agent.supported_actions(), + *self.app_config.supported_actions(), *self.app_config_allow_list.supported_actions(), *self.app_config_definition.supported_actions(), + *self.app_config_fragment.supported_actions(), *self.domain.supported_actions(), *self.dotfile.supported_actions(), *self.error_log.supported_actions(), diff --git a/tests/unit/client_v2/test_app_config_allow_list.py b/tests/unit/client_v2/test_app_config_allow_list.py index 2df642b01e3..2a01a1a31b2 100644 --- a/tests/unit/client_v2/test_app_config_allow_list.py +++ b/tests/unit/client_v2/test_app_config_allow_list.py @@ -8,7 +8,7 @@ from ai.backend.client.v2.base_client import BackendAIAuthClient from ai.backend.client.v2.config import ClientConfig from ai.backend.client.v2.domains_v2.app_config_allow_list import V2AppConfigAllowListClient -from ai.backend.common.data.app_config.types import AppConfigScopeType +from ai.backend.common.data.app_config.types import AppConfigAccessLevel, AppConfigScopeType from ai.backend.common.dto.manager.v2.app_config_allow_list.request import ( CreateAppConfigAllowListInput, SearchAppConfigAllowListInput, @@ -47,6 +47,8 @@ def _node_payload(entry_id: str, config_name: str) -> dict[str, str | int]: "config_name": config_name, "scope_type": "domain", "rank": 200, + "read_access": "authenticated", + "write_access": "admin", "created_at": "2026-01-01T00:00:00+00:00", "updated_at": "2026-01-02T00:00:00+00:00", } @@ -77,6 +79,8 @@ async def test_happy_path(self) -> None: assert result.app_config_allow_list.config_name == "theme" assert result.app_config_allow_list.scope_type == AppConfigScopeType.DOMAIN assert result.app_config_allow_list.rank == 200 + assert result.app_config_allow_list.read_access == AppConfigAccessLevel.AUTHENTICATED + assert result.app_config_allow_list.write_access == AppConfigAccessLevel.ADMIN class TestAdminSearch: @@ -135,7 +139,11 @@ async def test_happy_path(self) -> None: result = await client.admin_update( entry_id, - UpdateAppConfigAllowListInput(id=entry_id, rank=250), + UpdateAppConfigAllowListInput( + id=entry_id, + rank=250, + write_access=AppConfigAccessLevel.OWNER, + ), ) call_args = mock_session.request.call_args @@ -143,6 +151,9 @@ async def test_happy_path(self) -> None: assert str(call_args[0][1]).endswith(f"/v2/app-config-allow-list/{entry_id}") assert isinstance(result, UpdateAppConfigAllowListPayload) assert result.app_config_allow_list.rank == 250 + # response access tiers round-trip through the typed response model + assert result.app_config_allow_list.read_access == AppConfigAccessLevel.AUTHENTICATED + assert result.app_config_allow_list.write_access == AppConfigAccessLevel.ADMIN class TestAdminPurge: diff --git a/tests/unit/manager/api/gql/test_app_config_allow_list_types.py b/tests/unit/manager/api/gql/test_app_config_allow_list_types.py index d40da2a7f40..7c00f0495e1 100644 --- a/tests/unit/manager/api/gql/test_app_config_allow_list_types.py +++ b/tests/unit/manager/api/gql/test_app_config_allow_list_types.py @@ -5,7 +5,7 @@ import uuid from datetime import UTC, datetime -from ai.backend.common.data.app_config.types import AppConfigScopeType +from ai.backend.common.data.app_config.types import AppConfigAccessLevel, AppConfigScopeType from ai.backend.common.dto.manager.v2.app_config_allow_list.response import ( AppConfigAllowListNode, ) @@ -31,6 +31,8 @@ def test_from_pydantic_maps_all_fields(self) -> None: config_name="theme", scope_type=AppConfigScopeType.DOMAIN, rank=200, + read_access=AppConfigAccessLevel.AUTHENTICATED, + write_access=AppConfigAccessLevel.ADMIN, created_at=created, updated_at=updated, ) @@ -40,6 +42,8 @@ def test_from_pydantic_maps_all_fields(self) -> None: assert gql.config_name == "theme" assert gql.scope_type == AppConfigScopeType.DOMAIN assert gql.rank == 200 + assert gql.read_access == AppConfigAccessLevel.AUTHENTICATED + assert gql.write_access == AppConfigAccessLevel.ADMIN assert gql.created_at == created assert gql.updated_at == updated diff --git a/tests/unit/manager/repositories/app_config_allow_list/test_repository.py b/tests/unit/manager/repositories/app_config_allow_list/test_repository.py index 3275f76e39f..0a0d7ea7b2a 100644 --- a/tests/unit/manager/repositories/app_config_allow_list/test_repository.py +++ b/tests/unit/manager/repositories/app_config_allow_list/test_repository.py @@ -8,7 +8,7 @@ import pytest import sqlalchemy as sa -from ai.backend.common.data.app_config.types import AppConfigScopeType +from ai.backend.common.data.app_config.types import AppConfigAccessLevel, AppConfigScopeType from ai.backend.common.data.filter_specs import StringMatchSpec from ai.backend.common.identifier.app_config_allow_list import AppConfigAllowListID from ai.backend.manager.data.app_config_allow_list.types import ( @@ -195,6 +195,59 @@ async def test_explicit_rank_overrides_default( assert (await repository.get_by_id(created.id)).rank == 250 +class TestAccessLevelAssignment: + @pytest.mark.parametrize( + ("scope_type", "expected_read", "expected_write"), + [ + (AppConfigScopeType.PUBLIC, AppConfigAccessLevel.PUBLIC, AppConfigAccessLevel.ADMIN), + ( + AppConfigScopeType.DOMAIN, + AppConfigAccessLevel.AUTHENTICATED, + AppConfigAccessLevel.ADMIN, + ), + (AppConfigScopeType.USER, AppConfigAccessLevel.OWNER, AppConfigAccessLevel.OWNER), + ], + ) + async def test_access_defaults_by_scope_type( + self, + repository: AppConfigAllowListRepository, + definition_repository: AppConfigDefinitionRepository, + scope_type: AppConfigScopeType, + expected_read: AppConfigAccessLevel, + expected_write: AppConfigAccessLevel, + ) -> None: + await _register(definition_repository, "theme") + created = await _create_entry(repository, "theme", scope_type) + assert created.read_access is expected_read + assert created.write_access is expected_write + # persisted, not just returned + fetched = await repository.get_by_id(created.id) + assert fetched.read_access is expected_read + assert fetched.write_access is expected_write + + async def test_explicit_access_overrides_default( + self, + repository: AppConfigAllowListRepository, + definition_repository: AppConfigDefinitionRepository, + ) -> None: + await _register(definition_repository, "theme") + # a public layer that only admins may read, overriding the world-readable default + created = await repository.create( + Creator( + spec=AppConfigAllowListCreatorSpec( + config_name="theme", + scope_type=AppConfigScopeType.PUBLIC, + read_access=AppConfigAccessLevel.ADMIN, + write_access=AppConfigAccessLevel.ADMIN, + ) + ) + ) + assert created.read_access is AppConfigAccessLevel.ADMIN + fetched = await repository.get_by_id(created.id) + assert fetched.read_access is AppConfigAccessLevel.ADMIN + assert fetched.write_access is AppConfigAccessLevel.ADMIN + + class TestUpdate: async def test_update_changes_rank( self, @@ -213,6 +266,29 @@ async def test_update_changes_rank( assert updated.config_name == existing_entry.config_name assert updated.scope_type is existing_entry.scope_type + async def test_update_changes_access_levels( + self, + repository: AppConfigAllowListRepository, + existing_entry: AppConfigAllowListData, + ) -> None: + # existing_entry is ("theme", PUBLIC) -> default read=public, write=admin. + updated = await repository.update( + Updater( + spec=AppConfigAllowListUpdaterSpec( + read_access=OptionalState.update(AppConfigAccessLevel.AUTHENTICATED), + write_access=OptionalState.update(AppConfigAccessLevel.OWNER), + ), + pk_value=existing_entry.id, + ) + ) + assert updated.read_access is AppConfigAccessLevel.AUTHENTICATED + assert updated.write_access is AppConfigAccessLevel.OWNER + fetched = await repository.get_by_id(existing_entry.id) + assert fetched.read_access is AppConfigAccessLevel.AUTHENTICATED + assert fetched.write_access is AppConfigAccessLevel.OWNER + # rank left untouched + assert fetched.rank == existing_entry.rank + async def test_update_missing_raises(self, repository: AppConfigAllowListRepository) -> None: with pytest.raises(AppConfigAllowListNotFound): await repository.update( diff --git a/tests/unit/manager/repositories/app_config_fragment/test_repository.py b/tests/unit/manager/repositories/app_config_fragment/test_repository.py index cf1a624040f..e4c597c6b1f 100644 --- a/tests/unit/manager/repositories/app_config_fragment/test_repository.py +++ b/tests/unit/manager/repositories/app_config_fragment/test_repository.py @@ -79,6 +79,8 @@ def _allow_list_row(config_name: str, scope_type: AppConfigScopeType) -> AppConf config_name=config_name, scope_type=scope_type, rank=scope_type.default_rank(), + read_access=scope_type.default_read_access(), + write_access=scope_type.default_write_access(), ) @@ -649,12 +651,18 @@ async def test_one_query_returns_public_domain_user_rank_ordered( or (f.scope_type is AppConfigScopeType.USER and f.scope_id == _USER_ID) ) ] - assert [f.id for f in applicable] == [f.id for f in expected] - assert [f.scope_type for f in applicable] == [ + assert [vf.data.id for vf in applicable] == [f.id for f in expected] + assert [vf.data.scope_type for vf in applicable] == [ AppConfigScopeType.PUBLIC, AppConfigScopeType.DOMAIN, AppConfigScopeType.USER, ] + # each visible layer carries its allow-list read_access tier (scope-type defaults) + assert [vf.read_access for vf in applicable] == [ + AppConfigScopeType.PUBLIC.default_read_access(), + AppConfigScopeType.DOMAIN.default_read_access(), + AppConfigScopeType.USER.default_read_access(), + ] async def test_unknown_config_name_returns_empty( self, @@ -687,11 +695,15 @@ async def test_bulk_returns_visible_fragments_for_all_names_ordered( or (f.scope_type is AppConfigScopeType.USER and f.scope_id == _USER_ID) ) } - assert {f.id for f in applicable} == expected + assert {vf.data.id for vf in applicable} == expected # Rank-ordered globally, so each config_name's subset stays rank-ordered # (public < domain < user) for the caller to group by name and deep-merge in order. for name in ("theme", "menu"): - ranks = [f.scope_type.default_rank() for f in applicable if f.config_name == name] + ranks = [ + vf.data.scope_type.default_rank() + for vf in applicable + if vf.data.config_name == name + ] assert ranks == sorted(ranks) async def test_bulk_empty_names_returns_empty( diff --git a/tests/unit/manager/services/app_config/BUILD b/tests/unit/manager/services/app_config/BUILD new file mode 100644 index 00000000000..57341b1358b --- /dev/null +++ b/tests/unit/manager/services/app_config/BUILD @@ -0,0 +1,3 @@ +python_tests( + name="tests", +) diff --git a/tests/unit/manager/services/app_config/__init__.py b/tests/unit/manager/services/app_config/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/manager/services/app_config/test_service.py b/tests/unit/manager/services/app_config/test_service.py new file mode 100644 index 00000000000..0016ae046e2 --- /dev/null +++ b/tests/unit/manager/services/app_config/test_service.py @@ -0,0 +1,325 @@ +"""Tests for AppConfigService (merged AppConfig resolution) with a mocked repository.""" + +from __future__ import annotations + +import uuid +from collections.abc import Callable +from datetime import UTC, datetime +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from ai.backend.common.data.app_config.types import AppConfigAccessLevel, AppConfigScopeType +from ai.backend.common.data.user.types import UserData, UserRole +from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID +from ai.backend.common.identifier.domain import DomainID +from ai.backend.common.identifier.user import UserID +from ai.backend.manager.data.app_config_fragment.types import ( + AppConfigFragmentData, + VisibleFragment, +) +from ai.backend.manager.repositories.app_config_fragment.repository import ( + AppConfigFragmentRepository, +) +from ai.backend.manager.repositories.app_config_fragment.types import AppConfigScopeArguments +from ai.backend.manager.services.app_config.actions.resolve import ResolveAppConfigAction +from ai.backend.manager.services.app_config.actions.resolve_bulk import ( + ResolveBulkAppConfigAction, +) +from ai.backend.manager.services.app_config.service import AppConfigService + +_USER_ID = UserID(uuid.uuid4()) +_DOMAIN_ID = DomainID(uuid.uuid4()) +_NOW = datetime.now(UTC) +_SCOPE = AppConfigScopeArguments(domain_id=_DOMAIN_ID, user_id=_USER_ID) + +# The caller behind _SCOPE: a regular user who owns the _USER_ID scope (satisfies the +# default read_access tiers — public, authenticated, and its own user layer). +_REQUESTER = UserData( + user_id=_USER_ID, + is_authorized=True, + is_admin=False, + is_superadmin=False, + role=UserRole.USER, + domain_name="default", +) +_SUPERADMIN = UserData( + user_id=UserID(uuid.uuid4()), + is_authorized=True, + is_admin=True, + is_superadmin=True, + role=UserRole.SUPERADMIN, + domain_name="default", +) + +FragmentFactory = Callable[[str, dict[str, Any], AppConfigScopeType, str], AppConfigFragmentData] + + +def _visible( + *fragments: AppConfigFragmentData, + read_access: AppConfigAccessLevel | None = None, +) -> list[VisibleFragment]: + """Wrap fragments as the read query now yields them — each paired with a ``read_access`` + tier (the scope-type default unless overridden), which the service filters against. + """ + return [ + VisibleFragment( + data=f, + read_access=read_access + if read_access is not None + else f.scope_type.default_read_access(), + ) + for f in fragments + ] + + +@pytest.fixture +def make_fragment() -> FragmentFactory: + """Factory for an ``AppConfigFragmentData`` — the caller names the fragment's identity + (``config_name``, ``config``, ``scope_type``, ``scope_id``); only the id and timestamps + (which the merge does not read) are filled in. + """ + + def _make( + config_name: str, + config: dict[str, Any], + scope_type: AppConfigScopeType, + scope_id: str, + ) -> AppConfigFragmentData: + return AppConfigFragmentData( + id=AppConfigFragmentID(uuid.uuid4()), + config_name=config_name, + scope_type=scope_type, + scope_id=scope_id, + config=config, + created_at=_NOW, + updated_at=_NOW, + ) + + return _make + + +class TestAppConfigService: + @pytest.fixture + def mock_fragment_repository(self) -> MagicMock: + return MagicMock(spec=AppConfigFragmentRepository) + + @pytest.fixture + def service(self, mock_fragment_repository: MagicMock) -> AppConfigService: + return AppConfigService(fragment_repository=mock_fragment_repository) + + async def test_resolve_deep_merges_applicable_fragments( + self, + service: AppConfigService, + mock_fragment_repository: MagicMock, + make_fragment: FragmentFactory, + ) -> None: + # Fragments come rank-ordered (low -> high), so the later one overrides on merge. + public = make_fragment( + "theme", {"theme": "light", "lang": "en"}, AppConfigScopeType.PUBLIC, "" + ) + user = make_fragment("theme", {"theme": "dark"}, AppConfigScopeType.USER, str(_USER_ID)) + mock_fragment_repository.list_visible_fragments_bulk = AsyncMock( + return_value=_visible(public, user) + ) + + result = await service.resolve_app_config( + ResolveAppConfigAction(config_name="theme", scope=_SCOPE, requester=_REQUESTER) + ) + + assert result.app_config.config_name == "theme" + assert result.app_config.fragments == [public, user] + assert result.app_config.merged_config == {"theme": "dark", "lang": "en"} + assert result.scope_id() == str(_USER_ID) + mock_fragment_repository.list_visible_fragments_bulk.assert_called_once_with( + ["theme"], _SCOPE + ) + + async def test_resolve_replaces_lists_wholesale( + self, + service: AppConfigService, + mock_fragment_repository: MagicMock, + make_fragment: FragmentFactory, + ) -> None: + # A higher-rank list overrides the lower one WHOLE — not blended element-by-index. + # Nested dicts still recurse (theme.light survives, theme.dark is added). + public = make_fragment( + "ui", + {"nav": ["home", "about", "contact"], "theme": {"light": True}}, + AppConfigScopeType.PUBLIC, + "", + ) + user = make_fragment( + "ui", + {"nav": ["dashboard"], "theme": {"dark": True}}, + AppConfigScopeType.USER, + str(_USER_ID), + ) + mock_fragment_repository.list_visible_fragments_bulk = AsyncMock( + return_value=_visible(public, user) + ) + + result = await service.resolve_app_config( + ResolveAppConfigAction(config_name="ui", scope=_SCOPE, requester=_REQUESTER) + ) + + # The user's shorter nav list fully replaces public's — no trailing "about"/"contact". + assert result.app_config.merged_config == { + "nav": ["dashboard"], + "theme": {"light": True, "dark": True}, + } + + async def test_resolve_empty_yields_none_merged_config( + self, service: AppConfigService, mock_fragment_repository: MagicMock + ) -> None: + mock_fragment_repository.list_visible_fragments_bulk = AsyncMock(return_value=_visible()) + + result = await service.resolve_app_config( + ResolveAppConfigAction(config_name="unknown", scope=_SCOPE, requester=_REQUESTER) + ) + + # No contributing fragment -> None (unconfigured), not an empty {}. + assert result.app_config.fragments == [] + assert result.app_config.merged_config is None + + async def test_resolve_bulk_groups_by_name_and_merges_each( + self, + service: AppConfigService, + mock_fragment_repository: MagicMock, + make_fragment: FragmentFactory, + ) -> None: + # Repo returns visible fragments for both names, (config_name, rank)-ordered. + theme_public = make_fragment( + "theme", {"theme": "light", "lang": "en"}, AppConfigScopeType.PUBLIC, "" + ) + theme_user = make_fragment( + "theme", {"theme": "dark"}, AppConfigScopeType.USER, str(_USER_ID) + ) + menu_public = make_fragment("menu", {"items": ["a"]}, AppConfigScopeType.PUBLIC, "") + mock_fragment_repository.list_visible_fragments_bulk = AsyncMock( + return_value=_visible(theme_public, theme_user, menu_public) + ) + + result = await service.resolve_app_config_bulk( + ResolveBulkAppConfigAction( + config_names=["theme", "menu", "unknown"], scope=_SCOPE, requester=_REQUESTER + ) + ) + + # One AppConfigData per requested name, in request order. + assert [c.config_name for c in result.app_configs] == ["theme", "menu", "unknown"] + assert result.app_configs[0].merged_config == {"theme": "dark", "lang": "en"} + assert result.app_configs[1].merged_config == {"items": ["a"]} + assert result.app_configs[2].merged_config is None # unregistered -> no fragments + assert result.scope_id() == str(_USER_ID) + mock_fragment_repository.list_visible_fragments_bulk.assert_called_once_with( + ["theme", "menu", "unknown"], _SCOPE + ) + + async def test_resolve_bulk_repeats_duplicate_config_names_in_output( + self, + service: AppConfigService, + mock_fragment_repository: MagicMock, + make_fragment: FragmentFactory, + ) -> None: + # A config_name repeated in the request must be repeated in the output — each + # position resolves independently, never collapsed into a single entry. + theme = make_fragment("theme", {"theme": "dark"}, AppConfigScopeType.PUBLIC, "") + mock_fragment_repository.list_visible_fragments_bulk = AsyncMock( + return_value=_visible(theme) + ) + + result = await service.resolve_app_config_bulk( + ResolveBulkAppConfigAction( + config_names=["theme", "theme"], scope=_SCOPE, requester=_REQUESTER + ) + ) + + assert [c.config_name for c in result.app_configs] == ["theme", "theme"] + assert result.app_configs[0].merged_config == {"theme": "dark"} + assert result.app_configs[1].merged_config == {"theme": "dark"} + + async def test_resolve_without_scope_merges_public_fragments_only( + self, + service: AppConfigService, + mock_fragment_repository: MagicMock, + make_fragment: FragmentFactory, + ) -> None: + # scope=None is the anonymous, pre-login read: only public fragments are queried, + # and the result is attributable to no user. + public = make_fragment( + "theme", {"theme": "light", "lang": "en"}, AppConfigScopeType.PUBLIC, "public" + ) + mock_fragment_repository.list_visible_fragments_bulk = AsyncMock( + return_value=_visible(public) + ) + + result = await service.resolve_app_config(ResolveAppConfigAction(config_name="theme")) + + assert result.app_config.config_name == "theme" + assert result.app_config.fragments == [public] + assert result.app_config.merged_config == {"theme": "light", "lang": "en"} + assert result._user_id is None + mock_fragment_repository.list_visible_fragments_bulk.assert_called_once_with( + ["theme"], None + ) + + async def test_resolve_drops_layer_the_requester_cannot_read( + self, + service: AppConfigService, + mock_fragment_repository: MagicMock, + make_fragment: FragmentFactory, + ) -> None: + # An admin has locked one layer to admin-only reads (non-default read_access): a + # regular caller sees it in scope but must not read it — it is dropped before merge. + public = make_fragment("theme", {"theme": "light"}, AppConfigScopeType.PUBLIC, "") + locked = make_fragment("theme", {"theme": "secret"}, AppConfigScopeType.USER, str(_USER_ID)) + mock_fragment_repository.list_visible_fragments_bulk = AsyncMock( + return_value=_visible(public) + _visible(locked, read_access=AppConfigAccessLevel.ADMIN) + ) + + result = await service.resolve_app_config( + ResolveAppConfigAction(config_name="theme", scope=_SCOPE, requester=_REQUESTER) + ) + + assert result.app_config.fragments == [public] + assert result.app_config.merged_config == {"theme": "light"} + + async def test_resolve_superadmin_reads_every_layer( + self, + service: AppConfigService, + mock_fragment_repository: MagicMock, + make_fragment: FragmentFactory, + ) -> None: + # A superadmin satisfies every tier, so an admin-locked layer still contributes. + public = make_fragment("theme", {"theme": "light"}, AppConfigScopeType.PUBLIC, "") + locked = make_fragment("theme", {"theme": "secret"}, AppConfigScopeType.USER, str(_USER_ID)) + mock_fragment_repository.list_visible_fragments_bulk = AsyncMock( + return_value=_visible(public) + _visible(locked, read_access=AppConfigAccessLevel.ADMIN) + ) + + result = await service.resolve_app_config( + ResolveAppConfigAction(config_name="theme", scope=_SCOPE, requester=_SUPERADMIN) + ) + + assert result.app_config.fragments == [public, locked] + assert result.app_config.merged_config == {"theme": "secret"} + + async def test_resolve_public_drops_non_public_readable_layer( + self, + service: AppConfigService, + mock_fragment_repository: MagicMock, + make_fragment: FragmentFactory, + ) -> None: + # Pre-login (requester=None): a layer whose read_access is above ``public`` — even a + # public-scope fragment an admin raised to authenticated-only — is not readable. + locked = make_fragment("theme", {"theme": "secret"}, AppConfigScopeType.PUBLIC, "") + mock_fragment_repository.list_visible_fragments_bulk = AsyncMock( + return_value=_visible(locked, read_access=AppConfigAccessLevel.AUTHENTICATED) + ) + + result = await service.resolve_app_config(ResolveAppConfigAction(config_name="theme")) + + assert result.app_config.fragments == [] + assert result.app_config.merged_config is None diff --git a/tests/unit/manager/services/app_config_allow_list/test_service.py b/tests/unit/manager/services/app_config_allow_list/test_service.py index 4a9a96bb011..c5d657705f6 100644 --- a/tests/unit/manager/services/app_config_allow_list/test_service.py +++ b/tests/unit/manager/services/app_config_allow_list/test_service.py @@ -8,7 +8,7 @@ import pytest -from ai.backend.common.data.app_config.types import AppConfigScopeType +from ai.backend.common.data.app_config.types import AppConfigAccessLevel, AppConfigScopeType from ai.backend.common.identifier.app_config_allow_list import AppConfigAllowListID from ai.backend.manager.data.app_config_allow_list.types import ( AppConfigAllowListData, @@ -61,6 +61,8 @@ def allow_list_data(self) -> AppConfigAllowListData: config_name="theme", scope_type=AppConfigScopeType.USER, rank=AppConfigScopeType.USER.default_rank(), + read_access=AppConfigAccessLevel.OWNER, + write_access=AppConfigAccessLevel.OWNER, created_at=datetime.now(UTC), updated_at=datetime.now(UTC), ) diff --git a/tests/unit/manager/services/app_config_fragment/test_service.py b/tests/unit/manager/services/app_config_fragment/test_service.py index e594eb6da19..30a8a2e8ab4 100644 --- a/tests/unit/manager/services/app_config_fragment/test_service.py +++ b/tests/unit/manager/services/app_config_fragment/test_service.py @@ -8,19 +8,28 @@ import pytest -from ai.backend.common.data.app_config.types import AppConfigScopeType +from ai.backend.common.data.app_config.types import AppConfigAccessLevel, AppConfigScopeType from ai.backend.common.data.permission.types import ScopeType +from ai.backend.common.data.user.types import UserData, UserRole +from ai.backend.common.identifier.app_config_allow_list import AppConfigAllowListID from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID from ai.backend.common.identifier.domain import DomainID from ai.backend.common.identifier.user import UserID +from ai.backend.manager.data.app_config_allow_list.types import AppConfigAllowListData from ai.backend.manager.data.app_config_fragment.types import ( AppConfigFragmentBulkItemError, AppConfigFragmentBulkResult, AppConfigFragmentData, AppConfigFragmentSearchResult, ) -from ai.backend.manager.errors.app_config import AppConfigFragmentNotFound +from ai.backend.manager.errors.app_config import ( + AppConfigFragmentNotFound, + AppConfigFragmentWriteNotAllowed, +) from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow +from ai.backend.manager.repositories.app_config_allow_list.repository import ( + AppConfigAllowListRepository, +) from ai.backend.manager.repositories.app_config_fragment.creators import ( AppConfigFragmentCreatorSpec, ) @@ -72,6 +81,7 @@ _USER_UUID = uuid.uuid4() _USER_ID = str(_USER_UUID) +_OTHER_UUID = uuid.uuid4() def _fragment( @@ -92,37 +102,194 @@ def _fragment( ) +def _user( + user_uuid: uuid.UUID = _USER_UUID, + *, + is_admin: bool = False, + is_superadmin: bool = False, + domain: str = "default", + role: UserRole = UserRole.USER, +) -> UserData: + return UserData( + user_id=user_uuid, + is_authorized=True, + is_admin=is_admin, + is_superadmin=is_superadmin, + role=role, + domain_name=domain, + ) + + +def _superadmin() -> UserData: + return _user(is_admin=True, is_superadmin=True, role=UserRole.SUPERADMIN) + + +def _allow_entry( + scope_type: AppConfigScopeType, + *, + config_name: str = "theme", + write_access: AppConfigAccessLevel | None = None, + read_access: AppConfigAccessLevel | None = None, +) -> AppConfigAllowListData: + now = datetime.now(UTC) + return AppConfigAllowListData( + id=AppConfigAllowListID(uuid.uuid4()), + config_name=config_name, + scope_type=scope_type, + rank=scope_type.default_rank(), + read_access=read_access if read_access is not None else scope_type.default_read_access(), + write_access=write_access + if write_access is not None + else scope_type.default_write_access(), + created_at=now, + updated_at=now, + ) + + +def _user_spec(scope_id: str = _USER_ID) -> AppConfigFragmentCreatorSpec: + return AppConfigFragmentCreatorSpec( + config_name="theme", + scope_type=AppConfigScopeType.USER, + scope_id=scope_id, + config={"k": "v"}, + ) + + class TestAppConfigFragmentService: @pytest.fixture def mock_repository(self) -> MagicMock: return MagicMock(spec=AppConfigFragmentRepository) @pytest.fixture - def service(self, mock_repository: MagicMock) -> AppConfigFragmentService: - # The allow-list write-gate lives in the repository (atomic with the write); the - # service only delegates. Gate pass/reject is covered by the repository tests. - return AppConfigFragmentService(repository=mock_repository) + def mock_allow_list_repository(self) -> MagicMock: + # Default: the ("theme", user) layer exists with the default policy (write=owner). + repo = MagicMock(spec=AppConfigAllowListRepository) + repo.by_config_and_scope = AsyncMock(return_value=_allow_entry(AppConfigScopeType.USER)) + return repo + + @pytest.fixture + def service( + self, mock_repository: MagicMock, mock_allow_list_repository: MagicMock + ) -> AppConfigFragmentService: + return AppConfigFragmentService( + repository=mock_repository, + allow_list_repository=mock_allow_list_repository, + ) - # --- create --- + # --- create (delegation, authorized owner) --- async def test_create_delegates_to_repository( self, service: AppConfigFragmentService, mock_repository: MagicMock ) -> None: fragment = _fragment() mock_repository.create = AsyncMock(return_value=fragment) + spec = _user_spec() + + result = await service.create( + CreateAppConfigFragmentAction(creator_spec=spec, requester=_user()) + ) + + assert result.fragment == fragment + mock_repository.create.assert_called_once_with(Creator(spec=spec)) + + # --- write authorization --- + + async def test_create_non_owner_rejected( + self, service: AppConfigFragmentService, mock_repository: MagicMock + ) -> None: + mock_repository.create = AsyncMock() + with pytest.raises(AppConfigFragmentWriteNotAllowed): + await service.create( + CreateAppConfigFragmentAction( + creator_spec=_user_spec(), requester=_user(_OTHER_UUID) + ) + ) + mock_repository.create.assert_not_called() + + async def test_create_superadmin_writes_public( + self, + service: AppConfigFragmentService, + mock_repository: MagicMock, + mock_allow_list_repository: MagicMock, + ) -> None: + mock_allow_list_repository.by_config_and_scope = AsyncMock( + return_value=_allow_entry(AppConfigScopeType.PUBLIC) # write=admin + ) + fragment = _fragment(scope_type=AppConfigScopeType.PUBLIC, scope_id="public") + mock_repository.create = AsyncMock(return_value=fragment) spec = AppConfigFragmentCreatorSpec( config_name="theme", - scope_type=AppConfigScopeType.USER, - scope_id=_USER_ID, + scope_type=AppConfigScopeType.PUBLIC, + scope_id="public", config={"k": "v"}, ) - result = await service.create(CreateAppConfigFragmentAction(creator_spec=spec)) + result = await service.create( + CreateAppConfigFragmentAction(creator_spec=spec, requester=_superadmin()) + ) assert result.fragment == fragment - mock_repository.create.assert_called_once_with(Creator(spec=spec)) + mock_repository.create.assert_called_once() + + async def test_create_non_admin_public_rejected( + self, + service: AppConfigFragmentService, + mock_repository: MagicMock, + mock_allow_list_repository: MagicMock, + ) -> None: + mock_allow_list_repository.by_config_and_scope = AsyncMock( + return_value=_allow_entry(AppConfigScopeType.PUBLIC) # write=admin + ) + mock_repository.create = AsyncMock() + spec = AppConfigFragmentCreatorSpec( + config_name="theme", + scope_type=AppConfigScopeType.PUBLIC, + scope_id="public", + config={"k": "v"}, + ) + with pytest.raises(AppConfigFragmentWriteNotAllowed): + await service.create( + CreateAppConfigFragmentAction(creator_spec=spec, requester=_user()) + ) + mock_repository.create.assert_not_called() + + async def test_create_domain_non_admin_rejected( + self, + service: AppConfigFragmentService, + mock_repository: MagicMock, + mock_allow_list_repository: MagicMock, + ) -> None: + mock_allow_list_repository.by_config_and_scope = AsyncMock( + return_value=_allow_entry(AppConfigScopeType.DOMAIN) # write=admin + ) + mock_repository.create = AsyncMock() + spec = AppConfigFragmentCreatorSpec( + config_name="theme", + scope_type=AppConfigScopeType.DOMAIN, + scope_id="default", + config={"k": "v"}, + ) + with pytest.raises(AppConfigFragmentWriteNotAllowed): + await service.create( + CreateAppConfigFragmentAction(creator_spec=spec, requester=_user()) + ) + mock_repository.create.assert_not_called() - # --- get / search --- + async def test_create_missing_allow_list_rejected( + self, + service: AppConfigFragmentService, + mock_repository: MagicMock, + mock_allow_list_repository: MagicMock, + ) -> None: + mock_allow_list_repository.by_config_and_scope = AsyncMock(return_value=None) + mock_repository.create = AsyncMock() + with pytest.raises(AppConfigFragmentWriteNotAllowed): + await service.create( + CreateAppConfigFragmentAction(creator_spec=_user_spec(), requester=_user()) + ) + mock_repository.create.assert_not_called() + + # --- get / search (no authz) --- async def test_get(self, service: AppConfigFragmentService, mock_repository: MagicMock) -> None: fragment = _fragment() @@ -186,11 +353,12 @@ async def test_scoped_search_builds_domain_and_user_scopes( UserAppConfigFragmentTarget(user_id=UserID(_USER_UUID)), ], querier=querier, + # the owner of the returned user-scope fragment satisfies its read_access + requester=_user(), ) ) assert result.data == [fragment] - # queried_refs preserve the scoped principals (domain, then user). assert [ref.element_id for ref in result.queried_refs] == [str(domain_id), _USER_ID] mock_repository.scoped_search.assert_called_once() called_querier, called_scopes = mock_repository.scoped_search.call_args.args @@ -198,122 +366,211 @@ async def test_scoped_search_builds_domain_and_user_scopes( assert called_scopes[0].domain_id == domain_id assert called_scopes[1].user_id == _USER_UUID - # --- update --- + async def test_scoped_search_drops_fragment_the_requester_cannot_read( + self, service: AppConfigFragmentService, mock_repository: MagicMock + ) -> None: + # The page holds a user-scope fragment (default read_access=owner); a caller who is + # not its owner (and not admin) must not see it — it is filtered from the results. + fragment = _fragment(config_name="theme") # user scope, owned by _USER_UUID + mock_repository.scoped_search = AsyncMock( + return_value=AppConfigFragmentSearchResult( + items=[fragment], + total_count=1, + has_next_page=False, + has_previous_page=False, + ) + ) + + result = await service.scoped_search( + ScopedSearchAppConfigFragmentAction( + items=[UserAppConfigFragmentTarget(user_id=UserID(_OTHER_UUID))], + querier=BatchQuerier(pagination=OffsetPagination(limit=10, offset=0)), + requester=_user(_OTHER_UUID), + ) + ) + + assert result.data == [] - async def test_update_delegates_to_repository( + # --- update (loads fragment, then authorizes) --- + + async def test_update_owner_delegates_to_repository( self, service: AppConfigFragmentService, mock_repository: MagicMock ) -> None: - updated = _fragment() - mock_repository.update = AsyncMock(return_value=updated) + existing = _fragment() # USER scope, scope_id == _USER_ID + mock_repository.get_by_id = AsyncMock(return_value=existing) + mock_repository.update = AsyncMock(return_value=existing) updater = Updater( spec=AppConfigFragmentUpdaterSpec(config=OptionalState.update({"b": 2})), - pk_value=updated.id, + pk_value=existing.id, ) - result = await service.update(UpdateAppConfigFragmentAction(updater=updater)) + result = await service.update( + UpdateAppConfigFragmentAction(updater=updater, requester=_user()) + ) - assert result.fragment == updated + assert result.fragment == existing + mock_repository.get_by_id.assert_called_once_with(existing.id) mock_repository.update.assert_called_once_with(updater) + async def test_update_non_owner_rejected( + self, service: AppConfigFragmentService, mock_repository: MagicMock + ) -> None: + existing = _fragment() # owned by _USER_ID + mock_repository.get_by_id = AsyncMock(return_value=existing) + mock_repository.update = AsyncMock() + updater = Updater( + spec=AppConfigFragmentUpdaterSpec(config=OptionalState.update({"b": 2})), + pk_value=existing.id, + ) + + with pytest.raises(AppConfigFragmentWriteNotAllowed): + await service.update( + UpdateAppConfigFragmentAction(updater=updater, requester=_user(_OTHER_UUID)) + ) + mock_repository.update.assert_not_called() + # --- purge --- - async def test_purge_delegates_to_repository( + async def test_purge_owner_delegates_to_repository( self, service: AppConfigFragmentService, mock_repository: MagicMock ) -> None: - fragment = _fragment() - mock_repository.purge = AsyncMock(return_value=fragment) - purger = Purger(row_class=AppConfigFragmentRow, pk_value=fragment.id) + existing = _fragment() + mock_repository.get_by_id = AsyncMock(return_value=existing) + mock_repository.purge = AsyncMock(return_value=existing) + purger = Purger(row_class=AppConfigFragmentRow, pk_value=existing.id) - result = await service.purge(PurgeAppConfigFragmentAction(purger=purger)) + result = await service.purge(PurgeAppConfigFragmentAction(purger=purger, requester=_user())) - assert result.fragment == fragment + assert result.fragment == existing mock_repository.purge.assert_called_once_with(purger) + async def test_purge_non_owner_rejected( + self, service: AppConfigFragmentService, mock_repository: MagicMock + ) -> None: + existing = _fragment() + mock_repository.get_by_id = AsyncMock(return_value=existing) + mock_repository.purge = AsyncMock() + purger = Purger(row_class=AppConfigFragmentRow, pk_value=existing.id) + + with pytest.raises(AppConfigFragmentWriteNotAllowed): + await service.purge( + PurgeAppConfigFragmentAction(purger=purger, requester=_user(_OTHER_UUID)) + ) + mock_repository.purge.assert_not_called() + # --- bulk --- - async def test_bulk_create_delegates_to_repository( + async def test_bulk_create_owner_delegates( self, service: AppConfigFragmentService, mock_repository: MagicMock ) -> None: fragments = [_fragment(), _fragment()] mock_repository.bulk_create = AsyncMock( return_value=AppConfigFragmentBulkResult(succeeded=fragments, failed=[]) ) - specs = [ - AppConfigFragmentCreatorSpec( - config_name="theme", - scope_type=AppConfigScopeType.USER, - scope_id=_USER_ID, - config={"k": "v"}, - ) - ] + specs = [_user_spec(), _user_spec()] - result = await service.bulk_create(BulkCreateAppConfigFragmentAction(creator_specs=specs)) + result = await service.bulk_create( + BulkCreateAppConfigFragmentAction(creator_specs=specs, requester=_user()) + ) assert result.succeeded == fragments assert result.failed == [] mock_repository.bulk_create.assert_called_once_with(BulkCreator(specs=specs)) - async def test_bulk_update_delegates_to_repository( + async def test_bulk_create_mixed_authz_partial( self, service: AppConfigFragmentService, mock_repository: MagicMock ) -> None: - fragments = [_fragment(), _fragment()] - mock_repository.bulk_update = AsyncMock( - return_value=AppConfigFragmentBulkResult(succeeded=fragments, failed=[]) + # item 0 owned by the caller (authorized), item 1 owned by another user (rejected). + owned = _user_spec(_USER_ID) + foreign = _user_spec(str(_OTHER_UUID)) + created = _fragment() + mock_repository.bulk_create = AsyncMock( + return_value=AppConfigFragmentBulkResult(succeeded=[created], failed=[]) ) - updaters = [ - Updater( - spec=AppConfigFragmentUpdaterSpec(config=OptionalState.update({"b": 2})), - pk_value=fragments[0].id, + + result = await service.bulk_create( + BulkCreateAppConfigFragmentAction(creator_specs=[owned, foreign], requester=_user()) + ) + + mock_repository.bulk_create.assert_called_once_with(BulkCreator(specs=[owned])) + assert result.succeeded == [created] + assert [e.index for e in result.failed] == [1] + + async def test_bulk_create_remaps_repo_failure_indices( + self, service: AppConfigFragmentService, mock_repository: MagicMock + ) -> None: + # item 0 unauthorized (foreign), item 1 authorized but fails at the repo. + foreign = _user_spec(str(_OTHER_UUID)) + owned = _user_spec(_USER_ID) + mock_repository.bulk_create = AsyncMock( + return_value=AppConfigFragmentBulkResult( + succeeded=[], + failed=[AppConfigFragmentBulkItemError(index=0, message="db failure")], ) - ] + ) - result = await service.bulk_update(BulkUpdateAppConfigFragmentAction(updaters=updaters)) + result = await service.bulk_create( + BulkCreateAppConfigFragmentAction(creator_specs=[foreign, owned], requester=_user()) + ) - assert result.succeeded == fragments - assert result.failed == [] - mock_repository.bulk_update.assert_called_once_with(updaters) + # the repo saw only [owned] (repo failure index 0); it must remap to batch index 1, + # while the authz failure for [foreign] stays at index 0. + mock_repository.bulk_create.assert_called_once_with(BulkCreator(specs=[owned])) + assert sorted(e.index for e in result.failed) == [0, 1] - async def test_bulk_purge_delegates_to_repository( + async def test_bulk_update_authorizes_each_after_loading( self, service: AppConfigFragmentService, mock_repository: MagicMock ) -> None: - fragments = [_fragment(), _fragment()] - mock_repository.bulk_purge = AsyncMock( - return_value=AppConfigFragmentBulkResult(succeeded=fragments, failed=[]) + owned = _fragment(scope_id=_USER_ID) + foreign = _fragment(scope_id=str(_OTHER_UUID)) + mock_repository.get_by_id = AsyncMock(side_effect=[owned, foreign]) + mock_repository.bulk_update = AsyncMock( + return_value=AppConfigFragmentBulkResult(succeeded=[owned], failed=[]) ) - purgers = [ - Purger(row_class=AppConfigFragmentRow, pk_value=fragments[0].id), - Purger(row_class=AppConfigFragmentRow, pk_value=fragments[1].id), + updaters = [ + Updater( + spec=AppConfigFragmentUpdaterSpec(config=OptionalState.update({"b": 2})), + pk_value=owned.id, + ), + Updater( + spec=AppConfigFragmentUpdaterSpec(config=OptionalState.update({"b": 3})), + pk_value=foreign.id, + ), ] - result = await service.bulk_purge(BulkPurgeAppConfigFragmentAction(purgers=purgers)) + result = await service.bulk_update( + BulkUpdateAppConfigFragmentAction(updaters=updaters, requester=_user()) + ) - assert result.succeeded == fragments - assert result.failed == [] - mock_repository.bulk_purge.assert_called_once_with(purgers) + called_updaters = mock_repository.bulk_update.call_args.args[0] + assert [u.pk_value for u in called_updaters] == [owned.id] + assert result.succeeded == [owned] + assert [e.index for e in result.failed] == [1] - async def test_bulk_create_propagates_partial_failures( + async def test_bulk_purge_missing_fragment_is_per_item_failure( self, service: AppConfigFragmentService, mock_repository: MagicMock ) -> None: - # Partial success: the service must surface the repository's per-item failures - # (index + reason) on the action result, not just the succeeded fragments. - succeeded = [_fragment()] - failed = [AppConfigFragmentBulkItemError(index=1, message="write not allowed")] - mock_repository.bulk_create = AsyncMock( - return_value=AppConfigFragmentBulkResult(succeeded=succeeded, failed=failed) + owned = _fragment(scope_id=_USER_ID) + missing_id = AppConfigFragmentID(uuid.uuid4()) + mock_repository.get_by_id = AsyncMock( + side_effect=[owned, AppConfigFragmentNotFound(f"{missing_id} not found")] ) - specs = [ - AppConfigFragmentCreatorSpec( - config_name="theme", - scope_type=AppConfigScopeType.USER, - scope_id=_USER_ID, - config={"k": "v"}, - ) + mock_repository.bulk_purge = AsyncMock( + return_value=AppConfigFragmentBulkResult(succeeded=[owned], failed=[]) + ) + purgers = [ + Purger(row_class=AppConfigFragmentRow, pk_value=owned.id), + Purger(row_class=AppConfigFragmentRow, pk_value=missing_id), ] - result = await service.bulk_create(BulkCreateAppConfigFragmentAction(creator_specs=specs)) + result = await service.bulk_purge( + BulkPurgeAppConfigFragmentAction(purgers=purgers, requester=_user()) + ) - assert result.succeeded == succeeded - assert result.failed == failed + called_purgers = mock_repository.bulk_purge.call_args.args[0] + assert [p.pk_value for p in called_purgers] == [owned.id] + assert result.succeeded == [owned] + assert [e.index for e in result.failed] == [1] class TestCreateActionScope: @@ -344,3 +601,48 @@ def test_scope_follows_fragment_scope( ) assert action.scope_type() == expected_scope_type assert action.scope_id() == expected_scope_id + + +class TestAccessLevelPredicate: + """AppConfigAccessLevel.is_satisfied_by tier resolution.""" + + def test_anonymous_only_satisfies_public(self) -> None: + assert AppConfigAccessLevel.PUBLIC.is_satisfied_by(None, AppConfigScopeType.USER, _USER_ID) + assert not AppConfigAccessLevel.OWNER.is_satisfied_by( + None, AppConfigScopeType.USER, _USER_ID + ) + assert not AppConfigAccessLevel.ADMIN.is_satisfied_by( + None, AppConfigScopeType.PUBLIC, "public" + ) + + def test_superadmin_satisfies_every_tier(self) -> None: + for level in AppConfigAccessLevel: + assert level.is_satisfied_by(_superadmin(), AppConfigScopeType.PUBLIC, "public") + + def test_owner_user_scope(self) -> None: + assert AppConfigAccessLevel.OWNER.is_satisfied_by( + _user(), AppConfigScopeType.USER, _USER_ID + ) + assert not AppConfigAccessLevel.OWNER.is_satisfied_by( + _user(_OTHER_UUID), AppConfigScopeType.USER, _USER_ID + ) + + def test_owner_domain_scope_requires_domain_admin(self) -> None: + domain_admin = _user(is_admin=True, domain="default", role=UserRole.ADMIN) + assert AppConfigAccessLevel.OWNER.is_satisfied_by( + domain_admin, AppConfigScopeType.DOMAIN, "default" + ) + assert not AppConfigAccessLevel.OWNER.is_satisfied_by( + domain_admin, AppConfigScopeType.DOMAIN, "other-domain" + ) + assert not AppConfigAccessLevel.OWNER.is_satisfied_by( + _user(), AppConfigScopeType.DOMAIN, "default" + ) + + def test_admin_tier_needs_superadmin(self) -> None: + assert not AppConfigAccessLevel.ADMIN.is_satisfied_by( + _user(is_admin=True, role=UserRole.ADMIN), AppConfigScopeType.PUBLIC, "public" + ) + assert AppConfigAccessLevel.ADMIN.is_satisfied_by( + _superadmin(), AppConfigScopeType.PUBLIC, "public" + )