diff --git a/changes/6295.feature.md b/changes/6295.feature.md new file mode 100644 index 00000000000..0e022d9d30d --- /dev/null +++ b/changes/6295.feature.md @@ -0,0 +1 @@ +Add domain-level app configuration GraphQL API \ No newline at end of file diff --git a/docs/manager/graphql-reference/supergraph.graphql b/docs/manager/graphql-reference/supergraph.graphql index bce6104960c..ad8bfa49b7b 100644 --- a/docs/manager/graphql-reference/supergraph.graphql +++ b/docs/manager/graphql-reference/supergraph.graphql @@ -290,6 +290,13 @@ input AllowedGroups remove: [String] = [] } +"""Added in 25.16.0. App configuration data""" +type AppConfig + @join__type(graph: STRAWBERRY) +{ + extraConfig: JSON! +} + """ Added in 25.14.0. @@ -2044,6 +2051,24 @@ type DeleteDomain msg: String } +"""Added in 25.16.0. Input for deleting domain-level app configuration""" +input DeleteDomainConfigInput + @join__type(graph: STRAWBERRY) +{ + domainName: String! +} + +""" +Added in 25.16.0. +Payload returned after deleting domain-level app configuration. +Indicates whether the deletion was successful. +""" +type DeleteDomainConfigPayload + @join__type(graph: STRAWBERRY) +{ + deleted: Boolean! +} + """Added in 25.1.0.""" type DeleteEndpointAutoScalingRuleNode @join__type(graph: GRAPHENE) @@ -2171,6 +2196,28 @@ type DeleteUser msg: String } +""" +Added in 25.16.0. +Input for deleting user-level app configuration. +If user_id is not provided, the current user's configuration will be deleted. +""" +input DeleteUserConfigInput + @join__type(graph: STRAWBERRY) +{ + userId: ID = null +} + +""" +Added in 25.16.0. +Payload returned after deleting user-level app configuration. +Indicates whether the deletion was successful. +""" +type DeleteUserConfigPayload + @join__type(graph: STRAWBERRY) +{ + deleted: Boolean! +} + type DeleteUserResourcePolicy @join__type(graph: GRAPHENE) { @@ -4523,6 +4570,48 @@ type Mutation """ importArtifacts(input: ImportArtifactsInput!): ImportArtifactsPayload! @join__field(graph: STRAWBERRY) + """ + Added in 25.16.0. + Create or update domain-level app configuration. + The provided extra_config object will completely replace the existing configuration; + existing keys not present in the new extra_config will be removed. + All users in this domain will be affected by these settings when their configurations are merged, + unless they have user-level configurations that override specific keys. + Requires admin privileges. + """ + upsertDomainAppConfig(input: UpsertDomainConfigInput!): UpsertDomainConfigPayload! @join__field(graph: STRAWBERRY) + + """ + Added in 25.16.0. + Create or update user-level app configuration. + The provided extra_config object will completely replace the existing configuration; + existing keys not present in the new extra_config will be removed. + These settings will override domain-level settings when configurations are merged for this user. + If user_id is not provided, the current user's configuration will be updated. + Users can only modify their own configuration, but admins can modify any user's configuration. + """ + upsertUserAppConfig(input: UpsertUserConfigInput!): UpsertUserConfigPayload! @join__field(graph: STRAWBERRY) + + """ + Added in 25.16.0. + Delete domain-level app configuration. + All users in this domain may be affected by this deletion. + After deletion, users will only receive their user-level configurations + when configurations are merged, with no domain-level defaults. + Requires admin privileges. + """ + deleteDomainAppConfig(input: DeleteDomainConfigInput!): DeleteDomainConfigPayload! @join__field(graph: STRAWBERRY) + + """ + Added in 25.16.0. + Delete user-level app configuration. + After deletion, the user will still receive domain-level configuration values + when configurations are merged, as domain settings remain unaffected. + If user_id is not provided, the current user's configuration will be deleted. + Users can only delete their own configuration, but admins can delete any user's configuration. + """ + deleteUserAppConfig(input: DeleteUserConfigInput!): DeleteUserConfigPayload! @join__field(graph: STRAWBERRY) + """ Added in 25.15.0. @@ -5405,6 +5494,39 @@ type Query """ artifactRevisions(filter: ArtifactRevisionFilter = null, orderBy: [ArtifactRevisionOrderBy!] = null, before: String = null, after: String = null, first: Int = null, last: Int = null, limit: Int = null, offset: Int = null): ArtifactRevisionConnection! @join__field(graph: STRAWBERRY) + """ + Added in 25.16.0. + Retrieve domain-level app configuration. + Returns only the configuration set specifically for the domain, without merging. + This query is useful for checking what values are configured at the domain level + when you want to modify domain or user configurations separately. + For actual configuration values to be applied, use mergedAppConfig instead. + Requires admin privileges. + """ + domainAppConfig(domainName: String!): AppConfig @join__field(graph: STRAWBERRY) + + """ + Added in 25.16.0. + Retrieve user-level app configuration. + Returns only the configuration set specifically for the user, without merging with domain config. + This query is useful for checking what values are configured at the user level + when you want to modify domain or user configurations separately. + For actual configuration values to be applied, use mergedAppConfig instead. + If user_id is not provided, returns the current user's configuration. + Users can only access their own configuration, but admins can access any user's configuration. + """ + userAppConfig(userId: ID = null): AppConfig @join__field(graph: STRAWBERRY) + + """ + Added in 25.16.0. + Retrieve merged app configuration for the current user. + The result combines domain-level and user-level configurations, + where user settings override domain settings for the same keys. + This query should be used when working with user app configurations + to get the actual configuration values that will be applied. + """ + mergedAppConfig: AppConfig! @join__field(graph: STRAWBERRY) + """Added in 25.16.0""" deployments(filter: DeploymentFilter = null, orderBy: [DeploymentOrderBy!] = null, before: String = null, after: String = null, first: Int = null, last: Int = null, limit: Int = null, offset: Int = null): ModelDeploymentConnection! @join__field(graph: STRAWBERRY) @@ -6435,6 +6557,57 @@ type UpdateVFSStoragePayload vfsStorage: VFSStorage! } +""" +Added in 25.16.0. +Input for creating or updating domain-level app configuration. +The provided extra_config object will completely replace the existing configuration; +existing keys not present in the new extra_config will be removed. +All users in this domain will be affected by these settings when their configurations are merged. +""" +input UpsertDomainConfigInput + @join__type(graph: STRAWBERRY) +{ + domainName: String! + extraConfig: JSON! +} + +""" +Added in 25.16.0. +Payload returned after upserting domain-level app configuration. +Contains the resulting configuration that was stored. +""" +type UpsertDomainConfigPayload + @join__type(graph: STRAWBERRY) +{ + appConfig: AppConfig! +} + +""" +Added in 25.16.0. +Input for creating or updating user-level app configuration. +The provided extra_config object will completely replace the existing configuration; +existing keys not present in the new extra_config will be removed. +These settings will override domain-level settings when configurations are merged for this user. +If user_id is not provided, the current user's configuration will be updated. +""" +input UpsertUserConfigInput + @join__type(graph: STRAWBERRY) +{ + extraConfig: JSON! + userId: ID = null +} + +""" +Added in 25.16.0. +Payload returned after upserting user-level app configuration. +Contains the resulting configuration that was stored. +""" +type UpsertUserConfigPayload + @join__type(graph: STRAWBERRY) +{ + appConfig: AppConfig! +} + type User implements Item @join__implements(graph: GRAPHENE, interface: "Item") @join__type(graph: GRAPHENE) diff --git a/docs/manager/graphql-reference/v2-schema.graphql b/docs/manager/graphql-reference/v2-schema.graphql index ad853229088..4d90b4c083a 100644 --- a/docs/manager/graphql-reference/v2-schema.graphql +++ b/docs/manager/graphql-reference/v2-schema.graphql @@ -91,6 +91,11 @@ type AgentStats { totalResource: AgentResource! } +"""Added in 25.16.0. App configuration data""" +type AppConfig { + extraConfig: JSON! +} + """ Added in 25.14.0. @@ -790,6 +795,20 @@ type DeleteAutoScalingRulePayload { id: ID! } +"""Added in 25.16.0. Input for deleting domain-level app configuration""" +input DeleteDomainConfigInput { + domainName: String! +} + +""" +Added in 25.16.0. +Payload returned after deleting domain-level app configuration. +Indicates whether the deletion was successful. +""" +type DeleteDomainConfigPayload { + deleted: Boolean! +} + """Added in 25.14.0""" input DeleteHuggingFaceRegistryInput { id: ID! @@ -830,6 +849,24 @@ type DeleteReservoirRegistryPayload { id: ID! } +""" +Added in 25.16.0. +Input for deleting user-level app configuration. +If user_id is not provided, the current user's configuration will be deleted. +""" +input DeleteUserConfigInput { + userId: ID = null +} + +""" +Added in 25.16.0. +Payload returned after deleting user-level app configuration. +Indicates whether the deletion was successful. +""" +type DeleteUserConfigPayload { + deleted: Boolean! +} + """Added in 25.16.0. Input for deleting VFS storage""" input DeleteVFSStorageInput { id: ID! @@ -1331,6 +1368,48 @@ type Mutation { """ importArtifacts(input: ImportArtifactsInput!): ImportArtifactsPayload! + """ + Added in 25.16.0. + Create or update domain-level app configuration. + The provided extra_config object will completely replace the existing configuration; + existing keys not present in the new extra_config will be removed. + All users in this domain will be affected by these settings when their configurations are merged, + unless they have user-level configurations that override specific keys. + Requires admin privileges. + """ + upsertDomainAppConfig(input: UpsertDomainConfigInput!): UpsertDomainConfigPayload! + + """ + Added in 25.16.0. + Create or update user-level app configuration. + The provided extra_config object will completely replace the existing configuration; + existing keys not present in the new extra_config will be removed. + These settings will override domain-level settings when configurations are merged for this user. + If user_id is not provided, the current user's configuration will be updated. + Users can only modify their own configuration, but admins can modify any user's configuration. + """ + upsertUserAppConfig(input: UpsertUserConfigInput!): UpsertUserConfigPayload! + + """ + Added in 25.16.0. + Delete domain-level app configuration. + All users in this domain may be affected by this deletion. + After deletion, users will only receive their user-level configurations + when configurations are merged, with no domain-level defaults. + Requires admin privileges. + """ + deleteDomainAppConfig(input: DeleteDomainConfigInput!): DeleteDomainConfigPayload! + + """ + Added in 25.16.0. + Delete user-level app configuration. + After deletion, the user will still receive domain-level configuration values + when configurations are merged, as domain settings remain unaffected. + If user_id is not provided, the current user's configuration will be deleted. + Users can only delete their own configuration, but admins can delete any user's configuration. + """ + deleteUserAppConfig(input: DeleteUserConfigInput!): DeleteUserConfigPayload! + """ Added in 25.15.0. @@ -1635,6 +1714,39 @@ type Query { """ artifactRevisions(filter: ArtifactRevisionFilter = null, orderBy: [ArtifactRevisionOrderBy!] = null, before: String = null, after: String = null, first: Int = null, last: Int = null, limit: Int = null, offset: Int = null): ArtifactRevisionConnection! + """ + Added in 25.16.0. + Retrieve domain-level app configuration. + Returns only the configuration set specifically for the domain, without merging. + This query is useful for checking what values are configured at the domain level + when you want to modify domain or user configurations separately. + For actual configuration values to be applied, use mergedAppConfig instead. + Requires admin privileges. + """ + domainAppConfig(domainName: String!): AppConfig + + """ + Added in 25.16.0. + Retrieve user-level app configuration. + Returns only the configuration set specifically for the user, without merging with domain config. + This query is useful for checking what values are configured at the user level + when you want to modify domain or user configurations separately. + For actual configuration values to be applied, use mergedAppConfig instead. + If user_id is not provided, returns the current user's configuration. + Users can only access their own configuration, but admins can access any user's configuration. + """ + userAppConfig(userId: ID = null): AppConfig + + """ + Added in 25.16.0. + Retrieve merged app configuration for the current user. + The result combines domain-level and user-level configurations, + where user settings override domain settings for the same keys. + This query should be used when working with user app configurations + to get the actual configuration values that will be applied. + """ + mergedAppConfig: AppConfig! + """Added in 25.16.0""" deployments(filter: DeploymentFilter = null, orderBy: [DeploymentOrderBy!] = null, before: String = null, after: String = null, first: Int = null, last: Int = null, limit: Int = null, offset: Int = null): ModelDeploymentConnection! @@ -2206,6 +2318,49 @@ type UpdateVFSStoragePayload { vfsStorage: VFSStorage! } +""" +Added in 25.16.0. +Input for creating or updating domain-level app configuration. +The provided extra_config object will completely replace the existing configuration; +existing keys not present in the new extra_config will be removed. +All users in this domain will be affected by these settings when their configurations are merged. +""" +input UpsertDomainConfigInput { + domainName: String! + extraConfig: JSON! +} + +""" +Added in 25.16.0. +Payload returned after upserting domain-level app configuration. +Contains the resulting configuration that was stored. +""" +type UpsertDomainConfigPayload { + appConfig: AppConfig! +} + +""" +Added in 25.16.0. +Input for creating or updating user-level app configuration. +The provided extra_config object will completely replace the existing configuration; +existing keys not present in the new extra_config will be removed. +These settings will override domain-level settings when configurations are merged for this user. +If user_id is not provided, the current user's configuration will be updated. +""" +input UpsertUserConfigInput { + extraConfig: JSON! + userId: ID = null +} + +""" +Added in 25.16.0. +Payload returned after upserting user-level app configuration. +Contains the resulting configuration that was stored. +""" +type UpsertUserConfigPayload { + appConfig: AppConfig! +} + extend type UserNode @key(fields: "id") { id: ID! @external } diff --git a/docs/manager/rest-reference/openapi.json b/docs/manager/rest-reference/openapi.json index 28d5de4206e..8798995f657 100644 --- a/docs/manager/rest-reference/openapi.json +++ b/docs/manager/rest-reference/openapi.json @@ -5038,8 +5038,13 @@ "description": "Successful response" } }, + "security": [ + { + "TokenAuth": [] + } + ], "parameters": [], - "description": "\nGraphQL endpoint using Strawberry schema.\n\nSupports both query/mutation via POST and subscriptions via WebSocket.\n" + "description": "\nGraphQL endpoint using Strawberry schema.\n\nSupports both query/mutation via POST and subscriptions via WebSocket.\n\n\n**Preconditions:**\n* User privilege required.\n" }, "post": { "operationId": "root.handle_graphql_strawberry.2", @@ -5051,8 +5056,13 @@ "description": "Successful response" } }, + "security": [ + { + "TokenAuth": [] + } + ], "parameters": [], - "description": "\nGraphQL endpoint using Strawberry schema.\n\nSupports both query/mutation via POST and subscriptions via WebSocket.\n" + "description": "\nGraphQL endpoint using Strawberry schema.\n\nSupports both query/mutation via POST and subscriptions via WebSocket.\n\n\n**Preconditions:**\n* User privilege required.\n" } }, "/spec/graphiql": { diff --git a/src/ai/backend/manager/api/admin.py b/src/ai/backend/manager/api/admin.py index 6331d10c079..98386ee0b64 100644 --- a/src/ai/backend/manager/api/admin.py +++ b/src/ai/backend/manager/api/admin.py @@ -21,6 +21,7 @@ from ai.backend.logging import BraceStyleAdapter from ai.backend.manager.api.gql.data_loader.registry import DataLoaderRegistry from ai.backend.manager.api.gql.types import StrawberryGQLContext +from ai.backend.manager.errors.auth import AuthorizationFailed from ..api.gql.schema import schema as strawberry_schema from ..errors.api import GraphQLError as BackendGQLError @@ -35,7 +36,7 @@ from .auth import auth_required from .manager import GQLMutationUnfrozenRequiredMiddleware from .types import CORSOptions, WebMiddleware -from .utils import check_api_params +from .utils import check_api_params, set_handler_attr if TYPE_CHECKING: from graphql import FieldNode @@ -56,6 +57,14 @@ def __init__(self, *args, **kwargs) -> None: Supports both query/mutation via POST and subscriptions via WebSocket. """ + # Set handler attributes for middleware compatibility + set_handler_attr(self, "auth_required", True) + set_handler_attr(self, "auth_scope", "user") + + async def __call__(self, request: web.Request) -> web.StreamResponse: + if request.get("is_authorized", False): + return await super().__call__(request) + raise AuthorizationFailed("Unauthorized access to GraphQL endpoint") async def get_context( # type: ignore[override] self, request: web.Request, response: web.Response | web.WebSocketResponse diff --git a/src/ai/backend/manager/api/gql/app_config.py b/src/ai/backend/manager/api/gql/app_config.py new file mode 100644 index 00000000000..e2025669b1d --- /dev/null +++ b/src/ai/backend/manager/api/gql/app_config.py @@ -0,0 +1,402 @@ +"""GraphQL types and operations for app configuration.""" + +from __future__ import annotations + +from typing import Optional + +import strawberry +from strawberry import ID, Info + +from ai.backend.common.contexts.user import current_user +from ai.backend.manager.api.gql.utils import dedent_strip +from ai.backend.manager.data.app_config.types import AppConfigModifier +from ai.backend.manager.errors.auth import InsufficientPrivilege +from ai.backend.manager.services.app_config.actions import ( + DeleteDomainConfigAction, + DeleteUserConfigAction, + GetDomainConfigAction, + GetMergedAppConfigAction, + GetUserConfigAction, + UpsertDomainConfigAction, + UpsertUserConfigAction, +) +from ai.backend.manager.types import OptionalState + +from .types import StrawberryGQLContext + + +@strawberry.type(description="Added in 25.16.0. App configuration data") +class AppConfig: + """GraphQL type for app configuration.""" + + extra_config: strawberry.scalars.JSON + + +@strawberry.input( + description=dedent_strip( + """\ + Added in 25.16.0. + Input for creating or updating domain-level app configuration. + The provided extra_config object will completely replace the existing configuration; + existing keys not present in the new extra_config will be removed. + All users in this domain will be affected by these settings when their configurations are merged. + """ + ) +) +class UpsertDomainConfigInput: + """Input type for upserting domain-level app configuration.""" + + domain_name: str + extra_config: strawberry.scalars.JSON + + def to_modifier(self) -> AppConfigModifier: + return AppConfigModifier(extra_config=OptionalState.update(self.extra_config)) + + +@strawberry.input( + description=dedent_strip( + """\ + Added in 25.16.0. + Input for creating or updating user-level app configuration. + The provided extra_config object will completely replace the existing configuration; + existing keys not present in the new extra_config will be removed. + These settings will override domain-level settings when configurations are merged for this user. + If user_id is not provided, the current user's configuration will be updated. + """ + ) +) +class UpsertUserConfigInput: + """Input type for upserting user-level app configuration.""" + + extra_config: strawberry.scalars.JSON + user_id: Optional[ID] = None + + def to_modifier(self) -> AppConfigModifier: + return AppConfigModifier(extra_config=OptionalState.update(self.extra_config)) + + +@strawberry.input(description="Added in 25.16.0. Input for deleting domain-level app configuration") +class DeleteDomainConfigInput: + """Input type for deleting domain-level app configuration.""" + + domain_name: str + + +@strawberry.input( + description=dedent_strip( + """\ + Added in 25.16.0. + Input for deleting user-level app configuration. + If user_id is not provided, the current user's configuration will be deleted. + """ + ) +) +class DeleteUserConfigInput: + """Input type for deleting user-level app configuration.""" + + user_id: Optional[ID] = None + + +@strawberry.type( + description=dedent_strip( + """\ + Added in 25.16.0. + Payload returned after upserting domain-level app configuration. + Contains the resulting configuration that was stored. + """ + ) +) +class UpsertDomainConfigPayload: + """Payload returned after upserting domain-level app configuration.""" + + app_config: AppConfig + + +@strawberry.type( + description=dedent_strip( + """\ + Added in 25.16.0. + Payload returned after upserting user-level app configuration. + Contains the resulting configuration that was stored. + """ + ) +) +class UpsertUserConfigPayload: + """Payload returned after upserting user-level app configuration.""" + + app_config: AppConfig + + +@strawberry.type( + description=dedent_strip( + """\ + Added in 25.16.0. + Payload returned after deleting domain-level app configuration. + Indicates whether the deletion was successful. + """ + ) +) +class DeleteDomainConfigPayload: + """Payload returned after deleting domain-level app configuration.""" + + deleted: bool + + +@strawberry.type( + description=dedent_strip( + """\ + Added in 25.16.0. + Payload returned after deleting user-level app configuration. + Indicates whether the deletion was successful. + """ + ) +) +class DeleteUserConfigPayload: + """Payload returned after deleting user-level app configuration.""" + + deleted: bool + + +@strawberry.field( + description=dedent_strip( + """\ + Added in 25.16.0. + Retrieve domain-level app configuration. + Returns only the configuration set specifically for the domain, without merging. + This query is useful for checking what values are configured at the domain level + when you want to modify domain or user configurations separately. + For actual configuration values to be applied, use mergedAppConfig instead. + Requires admin privileges. + """ + ) +) +async def domain_app_config( + domain_name: str, + info: Info[StrawberryGQLContext], +) -> Optional[AppConfig]: + """Get domain-level app configuration.""" + processors = info.context.processors + me = current_user() + if me is None or not (me.is_admin or me.is_superadmin): + raise InsufficientPrivilege("Admin privileges required to access domain configuration") + + action_result = await processors.app_config.get_domain_config.wait_for_complete( + GetDomainConfigAction(domain_name=domain_name) + ) + + if not action_result.result: + return None + + return AppConfig(extra_config=action_result.result.extra_config) + + +@strawberry.field( + description=dedent_strip( + """\ + Added in 25.16.0. + Retrieve user-level app configuration. + Returns only the configuration set specifically for the user, without merging with domain config. + This query is useful for checking what values are configured at the user level + when you want to modify domain or user configurations separately. + For actual configuration values to be applied, use mergedAppConfig instead. + If user_id is not provided, returns the current user's configuration. + Users can only access their own configuration, but admins can access any user's configuration. + """ + ) +) +async def user_app_config( + info: Info[StrawberryGQLContext], + user_id: Optional[ID] = None, +) -> Optional[AppConfig]: + """Get user-level app configuration.""" + processors = info.context.processors + me = current_user() + if me is None: + raise InsufficientPrivilege("Authentication required") + + # Use current user's ID if user_id is not provided + target_user_id = str(user_id) if user_id is not None else str(me.user_id) + + if str(me.user_id) != target_user_id and not (me.is_admin or me.is_superadmin): + raise InsufficientPrivilege("Cannot access another user's app configuration") + + action_result = await processors.app_config.get_user_config.wait_for_complete( + GetUserConfigAction(user_id=target_user_id) + ) + + if not action_result.result: + return None + + return AppConfig(extra_config=action_result.result.extra_config) + + +@strawberry.field( + description=dedent_strip( + """\ + Added in 25.16.0. + Retrieve merged app configuration for the current user. + The result combines domain-level and user-level configurations, + where user settings override domain settings for the same keys. + This query should be used when working with user app configurations + to get the actual configuration values that will be applied. + """ + ) +) +async def merged_app_config( + info: Info[StrawberryGQLContext], +) -> AppConfig: + """Get merged app configuration for the current user.""" + processors = info.context.processors + me = current_user() + if me is None: + raise InsufficientPrivilege("Authentication required") + + action_result = await processors.app_config.get_merged_config.wait_for_complete( + GetMergedAppConfigAction(user_id=str(me.user_id)) + ) + + return AppConfig(extra_config=action_result.merged_config) + + +@strawberry.mutation( + name="upsertDomainAppConfig", + description=dedent_strip( + """\ + Added in 25.16.0. + Create or update domain-level app configuration. + The provided extra_config object will completely replace the existing configuration; + existing keys not present in the new extra_config will be removed. + All users in this domain will be affected by these settings when their configurations are merged, + unless they have user-level configurations that override specific keys. + Requires admin privileges. + """ + ), +) +async def upsert_domain_app_config( + input: UpsertDomainConfigInput, + info: Info[StrawberryGQLContext], +) -> UpsertDomainConfigPayload: + """Create or update domain-level app configuration.""" + processors = info.context.processors + me = current_user() + if me is None or not (me.is_admin or me.is_superadmin): + raise InsufficientPrivilege("Admin privileges required to modify domain configuration") + + action_result = await processors.app_config.upsert_domain_config.wait_for_complete( + UpsertDomainConfigAction( + domain_name=input.domain_name, + modifier=input.to_modifier(), + ) + ) + + return UpsertDomainConfigPayload( + app_config=AppConfig(extra_config=action_result.result.extra_config) + ) + + +@strawberry.mutation( + name="upsertUserAppConfig", + description=dedent_strip( + """\ + Added in 25.16.0. + Create or update user-level app configuration. + The provided extra_config object will completely replace the existing configuration; + existing keys not present in the new extra_config will be removed. + These settings will override domain-level settings when configurations are merged for this user. + If user_id is not provided, the current user's configuration will be updated. + Users can only modify their own configuration, but admins can modify any user's configuration. + """ + ), +) +async def upsert_user_app_config( + input: UpsertUserConfigInput, + info: Info[StrawberryGQLContext], +) -> UpsertUserConfigPayload: + """Create or update user-level app configuration.""" + processors = info.context.processors + me = current_user() + if me is None: + raise InsufficientPrivilege("Authentication required") + + # Use current user's ID if user_id is not provided + target_user_id = str(input.user_id) if input.user_id is not None else str(me.user_id) + + if str(me.user_id) != target_user_id and not (me.is_admin or me.is_superadmin): + raise InsufficientPrivilege("Cannot modify another user's app configuration") + + action_result = await processors.app_config.upsert_user_config.wait_for_complete( + UpsertUserConfigAction( + user_id=target_user_id, + modifier=input.to_modifier(), + ) + ) + + return UpsertUserConfigPayload( + app_config=AppConfig(extra_config=action_result.result.extra_config) + ) + + +@strawberry.mutation( + name="deleteDomainAppConfig", + description=dedent_strip( + """\ + Added in 25.16.0. + Delete domain-level app configuration. + All users in this domain may be affected by this deletion. + After deletion, users will only receive their user-level configurations + when configurations are merged, with no domain-level defaults. + Requires admin privileges. + """ + ), +) +async def delete_domain_app_config( + input: DeleteDomainConfigInput, + info: Info[StrawberryGQLContext], +) -> DeleteDomainConfigPayload: + """Delete domain-level app configuration.""" + processors = info.context.processors + me = current_user() + if me is None or not (me.is_admin or me.is_superadmin): + raise InsufficientPrivilege("Admin privileges required to delete domain configuration") + + action_result = await processors.app_config.delete_domain_config.wait_for_complete( + DeleteDomainConfigAction(domain_name=input.domain_name) + ) + + return DeleteDomainConfigPayload(deleted=action_result.deleted) + + +@strawberry.mutation( + name="deleteUserAppConfig", + description=dedent_strip( + """\ + Added in 25.16.0. + Delete user-level app configuration. + After deletion, the user will still receive domain-level configuration values + when configurations are merged, as domain settings remain unaffected. + If user_id is not provided, the current user's configuration will be deleted. + Users can only delete their own configuration, but admins can delete any user's configuration. + """ + ), +) +async def delete_user_app_config( + input: DeleteUserConfigInput, + info: Info[StrawberryGQLContext], +) -> DeleteUserConfigPayload: + """Delete user-level app configuration.""" + processors = info.context.processors + me = current_user() + if me is None: + raise InsufficientPrivilege("Authentication required") + + # Use current user's ID if user_id is not provided + target_user_id = str(input.user_id) if input.user_id is not None else str(me.user_id) + + if str(me.user_id) != target_user_id and not (me.is_admin or me.is_superadmin): + raise InsufficientPrivilege("Cannot delete another user's app configuration") + + action_result = await processors.app_config.delete_user_config.wait_for_complete( + DeleteUserConfigAction(user_id=target_user_id) + ) + + return DeleteUserConfigPayload(deleted=action_result.deleted) diff --git a/src/ai/backend/manager/api/gql/schema.py b/src/ai/backend/manager/api/gql/schema.py index 9a6bd0668de..ddbf5c0f0ad 100644 --- a/src/ai/backend/manager/api/gql/schema.py +++ b/src/ai/backend/manager/api/gql/schema.py @@ -5,6 +5,15 @@ from .agent_stats import ( agent_stats, ) +from .app_config import ( + delete_domain_app_config, + delete_user_app_config, + domain_app_config, + merged_app_config, + upsert_domain_app_config, + upsert_user_app_config, + user_app_config, +) from .artifact import ( approve_artifact_revision, artifact, @@ -96,6 +105,9 @@ class Query: artifacts = artifacts artifact_revision = artifact_revision artifact_revisions = artifact_revisions + domain_app_config = domain_app_config + user_app_config = user_app_config + merged_app_config = merged_app_config deployments = deployments deployment = deployment revisions = revisions @@ -121,6 +133,10 @@ class Mutation: scan_artifacts = scan_artifacts scan_artifact_models = scan_artifact_models import_artifacts = import_artifacts + upsert_domain_app_config = upsert_domain_app_config + upsert_user_app_config = upsert_user_app_config + delete_domain_app_config = delete_domain_app_config + delete_user_app_config = delete_user_app_config delegate_scan_artifacts = delegate_scan_artifacts delegate_import_artifacts = delegate_import_artifacts update_artifact = update_artifact diff --git a/src/ai/backend/manager/api/spec.py b/src/ai/backend/manager/api/spec.py index d73c6ef001a..ed7a4e44fe4 100644 --- a/src/ai/backend/manager/api/spec.py +++ b/src/ai/backend/manager/api/spec.py @@ -90,7 +90,7 @@