diff --git a/deployments/charts/backend-operator/templates/backend-worker.yaml b/deployments/charts/backend-operator/templates/backend-worker.yaml index f5feafc98..a0b58fdf4 100644 --- a/deployments/charts/backend-operator/templates/backend-worker.yaml +++ b/deployments/charts/backend-operator/templates/backend-worker.yaml @@ -104,6 +104,8 @@ spec: - {{ $name }}-{{ .Values.services.backendWorker.serviceName }} - --metrics_otel_enable - {{ .Values.sidecars.OTEL.enabled | quote }} + - --node_condition_prefix + - '{{ .Values.global.nodeConditionPrefix }}' - --progress_iter_frequency - {{ .Values.services.backendWorker.progressIterFrequency | quote }} {{- if .Values.global.backendTestNamespace }} diff --git a/deployments/charts/quick-start/templates/backend-operator-token-secret.yaml b/deployments/charts/quick-start/templates/backend-operator-token-secret.yaml index d0c0e9525..9e447ed61 100644 --- a/deployments/charts/quick-start/templates/backend-operator-token-secret.yaml +++ b/deployments/charts/quick-start/templates/backend-operator-token-secret.yaml @@ -89,7 +89,7 @@ spec: echo "Testing token validity..." TOKEN_TEST_RESPONSE=$(curl -s -w "%{http_code}" \ -H "Authorization: Bearer $SECRET_TOKEN" \ - $BASE_URL/api/auth/access_token \ + $BASE_URL/api/auth/jwt/access_token \ -G --data-urlencode "access_token=$SECRET_TOKEN" \ -o /tmp/token_test.json) diff --git a/deployments/charts/router/templates/_envoy-config-helpers.tpl b/deployments/charts/router/templates/_envoy-config-helpers.tpl index 5e33f2b6c..9f180d394 100644 --- a/deployments/charts/router/templates/_envoy-config-helpers.tpl +++ b/deployments/charts/router/templates/_envoy-config-helpers.tpl @@ -104,6 +104,8 @@ listeners: internal_only_headers: - x-osmo-auth-skip - x-osmo-user + - x-osmo-token-name + - x-osmo-workflow-id virtual_hosts: - name: service domains: ["*"] @@ -124,6 +126,8 @@ listeners: request_handle:headers():remove("x-osmo-auth-skip") request_handle:headers():remove("x-osmo-user") request_handle:headers():remove("x-osmo-roles") + request_handle:headers():remove("x-osmo-token-name") + request_handle:headers():remove("x-osmo-workflow-id") request_handle:headers():remove("x-envoy-internal") end - name: add-auth-skip @@ -439,8 +443,14 @@ listeners: -- Create the roles list local roles_list = table.concat(meta.verified_jwt.roles, ',') - -- Add the header + -- Add the headers request_handle:headers():replace('x-osmo-roles', roles_list) + if (meta.verified_jwt.osmo_token_name ~= nil) then + request_handle:headers():replace('x-osmo-token-name', tostring(meta.verified_jwt.osmo_token_name)) + end + if (meta.verified_jwt.osmo_workflow_id ~= nil) then + request_handle:headers():replace('x-osmo-workflow-id', tostring(meta.verified_jwt.osmo_workflow_id)) + end end {{- end }} - name: envoy.filters.http.router diff --git a/deployments/charts/service/templates/_envoy-config.tpl b/deployments/charts/service/templates/_envoy-config.tpl index 2fe580d0a..a56e20a4c 100644 --- a/deployments/charts/service/templates/_envoy-config.tpl +++ b/deployments/charts/service/templates/_envoy-config.tpl @@ -100,6 +100,8 @@ data: internal_only_headers: - x-osmo-auth-skip - x-osmo-user + - x-osmo-token-name + - x-osmo-workflow-id virtual_hosts: - name: service @@ -150,6 +152,8 @@ data: request_handle:headers():remove("x-osmo-auth-skip") request_handle:headers():remove("x-osmo-user") request_handle:headers():remove("x-osmo-roles") + request_handle:headers():remove("x-osmo-token-name") + request_handle:headers():remove("x-osmo-workflow-id") request_handle:headers():remove("x-envoy-internal") end - name: add-auth-skip @@ -516,8 +520,14 @@ data: -- Create the roles list local roles_list = table.concat(meta.verified_jwt.roles, ',') - -- Add the header + -- Add the headers request_handle:headers():replace('x-osmo-roles', roles_list) + if (meta.verified_jwt.osmo_token_name ~= nil) then + request_handle:headers():replace('x-osmo-token-name', tostring(meta.verified_jwt.osmo_token_name)) + end + if (meta.verified_jwt.osmo_workflow_id ~= nil) then + request_handle:headers():replace('x-osmo-workflow-id', tostring(meta.verified_jwt.osmo_workflow_id)) + end end {{- if .Values.sidecars.authz.enabled }} @@ -585,6 +595,8 @@ data: internal_only_headers: - x-osmo-auth-skip - x-osmo-user + - x-osmo-token-name + - x-osmo-workflow-id virtual_hosts: - name: service diff --git a/deployments/charts/service/templates/api-service.yaml b/deployments/charts/service/templates/api-service.yaml index 4bdb682e6..acb880426 100644 --- a/deployments/charts/service/templates/api-service.yaml +++ b/deployments/charts/service/templates/api-service.yaml @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -143,6 +143,10 @@ spec: - --log_name - api_service {{- end }} + {{- if .Values.services.defaultAdmin.enabled }} + - --default_admin_username + - {{ .Values.services.defaultAdmin.username | quote }} + {{- end }} {{- range $arg := .Values.services.service.extraArgs }} - {{ $arg | quote }} {{- end }} @@ -165,6 +169,13 @@ spec: name: redis-secret key: redis-password {{- end }} + {{- if .Values.services.defaultAdmin.enabled }} + - name: OSMO_DEFAULT_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.services.defaultAdmin.passwordSecretName }} + key: {{ .Values.services.defaultAdmin.passwordSecretKey }} + {{- end }} imagePullPolicy: {{ .Values.services.service.imagePullPolicy }} securityContext: allowPrivilegeEscalation: false diff --git a/deployments/charts/service/values.yaml b/deployments/charts/service/values.yaml index deb822dc6..b7f652ff6 100644 --- a/deployments/charts/service/values.yaml +++ b/deployments/charts/service/values.yaml @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -159,6 +159,28 @@ services: ## passwordSecretKey: password + ## Default admin user configuration + ## When configured, the service will create an admin user on startup with the osmo-admin role + ## and an access_token set to the specified password + ## + defaultAdmin: + ## Enable default admin user creation + ## When enabled, username and password must be configured + ## + enabled: false + + ## Username for the default admin user + ## + username: "admin" + + ## Name of the Kubernetes secret containing the default admin password + ## + passwordSecretName: default-admin-secret + + ## Key name in the secret that contains the default admin password + ## + passwordSecretKey: password + ## Redis cache service configuration ## Set enabled to false if using an external Redis deployment ## diff --git a/deployments/charts/web-ui/templates/_envoy-config-helpers.tpl b/deployments/charts/web-ui/templates/_envoy-config-helpers.tpl index 4b1d04eab..46c01122e 100644 --- a/deployments/charts/web-ui/templates/_envoy-config-helpers.tpl +++ b/deployments/charts/web-ui/templates/_envoy-config-helpers.tpl @@ -112,8 +112,14 @@ listeners: -- Create the roles list local roles_list = table.concat(meta.verified_jwt.roles, ',') - -- Add the header + -- Add the headers request_handle:headers():replace('x-osmo-roles', roles_list) + if (meta.verified_jwt.osmo_token_name ~= nil) then + request_handle:headers():replace('x-osmo-token-name', tostring(meta.verified_jwt.osmo_token_name)) + end + if (meta.verified_jwt.osmo_workflow_id ~= nil) then + request_handle:headers():replace('x-osmo-workflow-id', tostring(meta.verified_jwt.osmo_workflow_id)) + end end {{- end }} - name: envoy.filters.http.router @@ -157,6 +163,8 @@ name: service_routes internal_only_headers: - x-osmo-auth-skip - x-osmo-user +- x-osmo-token-name +- x-osmo-workflow-id virtual_hosts: - name: service domains: ["*"] @@ -208,6 +216,8 @@ Generate simplified Lua filters for UI chart -- Strip dangerous headers that should never come from external clients request_handle:headers():remove("x-osmo-auth-skip") request_handle:headers():remove("x-osmo-user") + request_handle:headers():remove("x-osmo-token-name") + request_handle:headers():remove("x-osmo-workflow-id") end - name: add-auth-skip typed_config: diff --git a/docs/deployment_guide/appendix/deploy_minimal.rst b/docs/deployment_guide/appendix/deploy_minimal.rst index 188fc87df..6732ed9f0 100644 --- a/docs/deployment_guide/appendix/deploy_minimal.rst +++ b/docs/deployment_guide/appendix/deploy_minimal.rst @@ -1,5 +1,5 @@ .. - SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -582,14 +582,24 @@ Step 8: Install Backend Operator $ osmo login https:// --method=dev --username=testuser -3. Create the account token secret: +3. Create the service account and token: - Generate a token for the backend operator with OSMO CLI: + Create a service account user and generate a token for the backend operator with OSMO CLI: .. code-block:: bash - $ export BACKEND_TOKEN=$(osmo token set backend-token --expires-at --description "Backend Operator Token" --service --roles osmo-backend -t json | jq -r '.token') + # Create the service account user + $ osmo user create backend-operator --roles osmo-backend + # Generate a token for the service account with the osmo-backend role + $ export BACKEND_TOKEN=$(osmo token set backend-token \ + --user backend-operator \ + --expires-at \ + --description "Backend Operator Token" \ + --roles osmo-backend \ + -t json | jq -r '.token') + + # Create the Kubernetes secret $ kubectl create secret generic osmo-operator-token --from-literal=token=$BACKEND_TOKEN --namespace osmo-operator diff --git a/docs/deployment_guide/getting_started/service_accounts.rst b/docs/deployment_guide/getting_started/service_accounts.rst new file mode 100644 index 000000000..717928465 --- /dev/null +++ b/docs/deployment_guide/getting_started/service_accounts.rst @@ -0,0 +1,332 @@ +.. + SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + SPDX-License-Identifier: Apache-2.0 + +.. _service_accounts: + +================ +Service Accounts +================ + +Service accounts provide programmatic access to OSMO for automation, CI/CD pipelines, and +backend operators. In OSMO, service accounts are regular users with Personal Access Tokens (PATs) +for API authentication. + +Overview +======== + +A service account consists of two components: + +1. **A user** — Represents the service account identity and holds role assignments +2. **A Personal Access Token (PAT)** — Provides authentication credentials for API access + +This approach provides several benefits: + +- **Unified role management** — Service accounts use the same role system as regular users +- **Centralized auditing** — All actions are attributed to the service account user +- **Flexible permissions** — Roles can be updated on the user, affecting future tokens +- **Easy token rotation** — Create a new token, update your systems, then delete the old token + +Creating a Service Account +========================== + +Follow these steps to create a service account for backend operators, CI/CD pipelines, +or other automation needs. + +Prerequisites +------------- + +- OSMO CLI installed and configured +- Admin privileges (``osmo-admin`` role) to create users and manage roles + +Step 1: Create the Service Account User +--------------------------------------- + +Create a user with an identifier that clearly indicates it's a service account: + +.. code-block:: bash + + $ osmo user create backend-operator --roles osmo-backend + +**Example output:** + +.. code-block:: text + + User created: backend-operator Roles assigned: osmo-backend + +.. tip:: + + Use a naming convention that distinguishes service accounts from regular users, + such as ``svc-`` (e.g., ``svc-backend-operator``, ``svc-monitoring``). + +Step 2: Create a Personal Access Token +-------------------------------------- + +Create a PAT for the service account. By default, the token inherits all roles from the user. +You can limit the token to specific roles using the ``--roles`` (or ``-r``) option. + +.. code-block:: bash + + $ osmo token set backend-token \ + --user backend-operator \ + --expires-at 2027-01-01 \ + --description "Backend Operator Token" \ + --roles osmo-backend + +**Example output:** + +.. code-block:: text + + Note: Save the token in a secure location as it will not be shown again + Access token: osmo_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + Created for user: backend-operator + Roles: osmo-backend + +.. tip:: + + If ``--roles`` is not specified, the token inherits all of the user's roles. + For service accounts, it's recommended to explicitly specify roles to follow the + principle of least privilege. + +.. important:: + + Save the token securely—it is only displayed once at creation time. + + +Managing Service Accounts +========================= + +List Service Account Users +-------------------------- + +List users with a specific prefix or role: + +.. code-block:: bash + + # List by naming prefix + $ osmo user list --id-prefix backend- + + # List by role + $ osmo user list --roles osmo-backend + +View Service Account Details +---------------------------- + +View details including assigned roles: + +.. code-block:: bash + + $ osmo user get backend-operator + +**Example output:** + +.. code-block:: text + + User ID: backend-operator Created At: 2026-01-15 + Created By: admin@example.com + + Roles: + - osmo-backend (assigned by admin@example.com on 2026-01-15) + +List Service Account Tokens +--------------------------- + +View all tokens for a service account: + +.. code-block:: bash + + $ osmo token list --user backend-operator + +Update Service Account Roles +---------------------------- + +Add or remove roles from a service account: + +.. code-block:: bash + + # Add a role + $ osmo user update backend-operator --add-roles osmo-ml-team + + # Remove a role + $ osmo user update backend-operator --remove-roles osmo-ml-team + +.. note:: + + When a role is removed from a user, it is automatically removed from all of that + user's PATs. + +Rotate a Service Account Token +------------------------------ + +To rotate a token: + +1. Create a new token: + + .. code-block:: bash + + $ osmo token set new-backend-token \ + --user backend-operator \ + --expires-at 2028-01-01 + +2. Update your systems to use the new token + +3. Delete the old token: + + .. code-block:: bash + + $ osmo token delete backend-token --user backend-operator + +Delete a Service Account +------------------------ + +Delete the service account user (this also deletes all associated tokens): + +.. code-block:: bash + + $ osmo user delete backend-operator + +.. seealso:: + + - :ref:`cli_reference_token` for token CLI reference + - :ref:`cli_reference_user` for user CLI reference + +Common Service Account Patterns +=============================== + +Backend Operator +---------------- + +For OSMO backend operators that manage compute resources: + +.. code-block:: bash + + # Create the service account + $ osmo user create backend-operator --roles osmo-backend + + # Create a token with appropriate expiration and specific roles + $ osmo token set backend-token \ + --user backend-operator \ + --expires-at 2027-01-01 \ + --description "Backend Operator - Production Cluster" \ + --roles osmo-backend + + # Store in Kubernetes + $ kubectl create secret generic osmo-operator-token \ + --from-literal=token=osmo_xxxxxxxxxx \ + --namespace osmo-operator + +See :ref:`deploy_backend` for complete backend operator deployment instructions. + +Monitoring and Automation +------------------------- + +For monitoring systems or automation scripts: + +.. code-block:: bash + + # Create the service account with read-only roles + $ osmo user create monitoring --roles osmo-user + + # Create a token with specific roles + $ osmo token set monitoring-token \ + --user monitoring \ + --expires-at 2027-01-01 \ + --description "Monitoring System" \ + --roles osmo-user + +**Using the token in a script:** + +.. code-block:: bash + + #!/bin/bash + # Monitoring script example + + # Login with the service account token + osmo login https://osmo.example.com --method=token --token-file=/etc/osmo/monitoring-token + + # Run monitoring commands + osmo workflow list --format-type json | process_metrics.py + +.. seealso:: + + - :ref:`access_tokens` for personal access token documentation + - :ref:`deploy_backend` for backend operator deployment + +Best Practices +============== + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Practice + - Description + * - **Use descriptive names** + - Name service accounts and tokens to clearly indicate their purpose + * - **Apply least privilege** + - Assign only the roles necessary for the service account's function + * - **Set appropriate expiration** + - Use expiration dates appropriate for your security requirements + * - **Rotate tokens regularly** + - Periodically create new tokens and delete old ones + * - **Use secret management** + - Store tokens in secure secret management systems, not in code or config files + * - **Monitor usage** + - Review service account activity in OSMO logs + +Troubleshooting +=============== + +Token Expired +------------- + +**Symptom:** Connection fails with error about expired token. + +**Solution:** Create a new token and update your systems: + +.. code-block:: bash + + $ osmo token set new-token \ + --user backend-operator \ + --expires-at 2028-01-01 + +Permission Denied +----------------- + +**Symptom:** API requests fail with permission denied errors. + +**Solution:** Check the service account's roles: + +.. code-block:: bash + + $ osmo user get backend-operator + +Add necessary roles if missing: + +.. code-block:: bash + + $ osmo user update backend-operator --add-roles osmo-backend + +User Not Found +-------------- + +**Symptom:** Cannot create token—user not found. + +**Solution:** Create the user first: + +.. code-block:: bash + + $ osmo user create backend-operator --roles osmo-backend diff --git a/docs/deployment_guide/index.rst b/docs/deployment_guide/index.rst index 01c31fd59..b9fd88a95 100644 --- a/docs/deployment_guide/index.rst +++ b/docs/deployment_guide/index.rst @@ -1,5 +1,5 @@ .. - SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -125,6 +125,7 @@ An OSMO deployment consists of two main components: getting_started/infrastructure_setup getting_started/deploy_service + getting_started/service_accounts getting_started/create_storage/index getting_started/configure_data diff --git a/docs/deployment_guide/install_backend/deploy_backend.rst b/docs/deployment_guide/install_backend/deploy_backend.rst index 8d7ef5566..d3f0c5566 100644 --- a/docs/deployment_guide/install_backend/deploy_backend.rst +++ b/docs/deployment_guide/install_backend/deploy_backend.rst @@ -1,5 +1,5 @@ .. - SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -31,21 +31,46 @@ Deploying the backend operator will register your compute backend with OSMO, mak .. _create_osmo_token: -Step 1: Create OSMO Service Token ------------------------------------------ +Step 1: Create Service Account for Backend Operator +---------------------------------------------------- -Create a service access token using OSMO CLI for backend operator authentication: +Create a service account and access token using OSMO CLI for backend operator authentication. + +First, log in to OSMO: .. code-block:: bash $ osmo login https://osmo.example.com - $ export OSMO_SERVICE_TOKEN=$(osmo token set backend-token --expires-at --description "Backend Operator Token" --service --roles osmo-backend -t json | jq -r '.token') +Create a service account user for the backend operator: + +.. code-block:: bash + + $ osmo user create backend-operator --roles osmo-backend + +Create a Access Token for the service account with the ``osmo-backend`` role: + +.. code-block:: bash + + $ export OSMO_SERVICE_TOKEN=$(osmo token set backend-token \ + --user backend-operator \ + --expires-at \ + --description "Backend Operator Token" \ + --roles osmo-backend \ + -t json | jq -r '.token') .. note:: Replace ```` with an expiration date in UTC format (YYYY-MM-DD). Save the token securely as it will not be shown again. +.. tip:: + + The ``--roles osmo-backend`` option limits the token to only the ``osmo-backend`` role. If omitted, the token inherits all roles from the user. + +.. seealso:: + + See :ref:`service_accounts` for more details on creating and managing service accounts. + Step 2: Create K8s Namespaces and Secrets ------------------------------------------------ @@ -179,4 +204,17 @@ Token Expiration Error Connection failed with error: {OSMOUserError: Token is expired, but no refresh token is present} -Check the ``osmo token list --service`` command to see if the token is expired. Follow :ref:`create_osmo_token` to create a new token. +Check if the token is expired by listing the service account's tokens: + +.. code-block:: bash + + $ osmo token list --user backend-operator + +If the token is expired, create a new one following :ref:`create_osmo_token`. Remember to update +the Kubernetes secret with the new token: + +.. code-block:: bash + + $ kubectl delete secret osmo-operator-token -n osmo-operator + $ kubectl create secret generic osmo-operator-token -n osmo-operator \ + --from-literal=token=$OSMO_SERVICE_TOKEN diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index 009d421b8..837308927 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -240,6 +240,7 @@ Preflight prem prepend Preprocess +programmatically prometheus psql py diff --git a/docs/user_guide/appendix/cli/cli_app.rst b/docs/user_guide/appendix/cli/cli_app.rst index eb0aa61f7..5da89b4a6 100644 --- a/docs/user_guide/appendix/cli/cli_app.rst +++ b/docs/user_guide/appendix/cli/cli_app.rst @@ -15,10 +15,10 @@ SPDX-License-Identifier: Apache-2.0 -.. _cli_reference_app: - :tocdepth: 3 +.. _cli_reference_app: + ================================================ osmo app ================================================ diff --git a/docs/user_guide/appendix/cli/cli_bucket.rst b/docs/user_guide/appendix/cli/cli_bucket.rst index 7288e426a..bb3aacf2a 100644 --- a/docs/user_guide/appendix/cli/cli_bucket.rst +++ b/docs/user_guide/appendix/cli/cli_bucket.rst @@ -15,10 +15,10 @@ SPDX-License-Identifier: Apache-2.0 -.. _cli_reference_bucket: - :tocdepth: 3 +.. _cli_reference_bucket: + ================================================ osmo bucket ================================================ diff --git a/docs/user_guide/appendix/cli/cli_credential.rst b/docs/user_guide/appendix/cli/cli_credential.rst index 5bd5f3cb1..b7639a944 100644 --- a/docs/user_guide/appendix/cli/cli_credential.rst +++ b/docs/user_guide/appendix/cli/cli_credential.rst @@ -15,10 +15,10 @@ SPDX-License-Identifier: Apache-2.0 -.. _cli_reference_credential: - :tocdepth: 3 +.. _cli_reference_credential: + ================================================ osmo credential ================================================ diff --git a/docs/user_guide/appendix/cli/cli_data.rst b/docs/user_guide/appendix/cli/cli_data.rst index 33e695491..9116a8931 100644 --- a/docs/user_guide/appendix/cli/cli_data.rst +++ b/docs/user_guide/appendix/cli/cli_data.rst @@ -15,10 +15,10 @@ SPDX-License-Identifier: Apache-2.0 -.. _cli_reference_data: - :tocdepth: 3 +.. _cli_reference_data: + ================================================ osmo data ================================================ diff --git a/docs/user_guide/appendix/cli/cli_dataset.rst b/docs/user_guide/appendix/cli/cli_dataset.rst index 5e2012a56..d356c9479 100644 --- a/docs/user_guide/appendix/cli/cli_dataset.rst +++ b/docs/user_guide/appendix/cli/cli_dataset.rst @@ -15,10 +15,10 @@ SPDX-License-Identifier: Apache-2.0 -.. _cli_reference_dataset: - :tocdepth: 3 +.. _cli_reference_dataset: + ================================================ osmo dataset ================================================ diff --git a/docs/user_guide/appendix/cli/cli_login.rst b/docs/user_guide/appendix/cli/cli_login.rst index ddd9eb8cc..a9c61e925 100644 --- a/docs/user_guide/appendix/cli/cli_login.rst +++ b/docs/user_guide/appendix/cli/cli_login.rst @@ -15,10 +15,10 @@ SPDX-License-Identifier: Apache-2.0 -.. _cli_reference_login: - :tocdepth: 3 +.. _cli_reference_login: + ================================================ osmo login ================================================ diff --git a/docs/user_guide/appendix/cli/cli_logout.rst b/docs/user_guide/appendix/cli/cli_logout.rst index b7fbb2e53..2ccc7d3f1 100644 --- a/docs/user_guide/appendix/cli/cli_logout.rst +++ b/docs/user_guide/appendix/cli/cli_logout.rst @@ -15,10 +15,10 @@ SPDX-License-Identifier: Apache-2.0 -.. _cli_reference_logout: - :tocdepth: 3 +.. _cli_reference_logout: + ================================================ osmo logout ================================================ diff --git a/docs/user_guide/appendix/cli/cli_pool.rst b/docs/user_guide/appendix/cli/cli_pool.rst index 00ede81bd..b5aefcd5d 100644 --- a/docs/user_guide/appendix/cli/cli_pool.rst +++ b/docs/user_guide/appendix/cli/cli_pool.rst @@ -15,10 +15,10 @@ SPDX-License-Identifier: Apache-2.0 -.. _cli_reference_pool: - :tocdepth: 3 +.. _cli_reference_pool: + ================================================ osmo pool ================================================ diff --git a/docs/user_guide/appendix/cli/cli_profile.rst b/docs/user_guide/appendix/cli/cli_profile.rst index 4a891f51a..f7ee0a455 100644 --- a/docs/user_guide/appendix/cli/cli_profile.rst +++ b/docs/user_guide/appendix/cli/cli_profile.rst @@ -15,10 +15,10 @@ SPDX-License-Identifier: Apache-2.0 -.. _cli_reference_profile: - :tocdepth: 3 +.. _cli_reference_profile: + ================================================ osmo profile ================================================ diff --git a/docs/user_guide/appendix/cli/cli_resource.rst b/docs/user_guide/appendix/cli/cli_resource.rst index d8555404d..2e4fd4c69 100644 --- a/docs/user_guide/appendix/cli/cli_resource.rst +++ b/docs/user_guide/appendix/cli/cli_resource.rst @@ -15,10 +15,10 @@ SPDX-License-Identifier: Apache-2.0 -.. _cli_reference_resource: - :tocdepth: 3 +.. _cli_reference_resource: + ================================================ osmo resource ================================================ diff --git a/docs/user_guide/appendix/cli/cli_task.rst b/docs/user_guide/appendix/cli/cli_task.rst index f4d8d52f5..920513264 100644 --- a/docs/user_guide/appendix/cli/cli_task.rst +++ b/docs/user_guide/appendix/cli/cli_task.rst @@ -15,10 +15,10 @@ SPDX-License-Identifier: Apache-2.0 -.. _cli_reference_task: - :tocdepth: 3 +.. _cli_reference_task: + ================================================ osmo task ================================================ diff --git a/docs/user_guide/appendix/cli/cli_token.rst b/docs/user_guide/appendix/cli/cli_token.rst index 72808ec40..384734a11 100644 --- a/docs/user_guide/appendix/cli/cli_token.rst +++ b/docs/user_guide/appendix/cli/cli_token.rst @@ -1,5 +1,5 @@ .. - SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,10 +15,10 @@ SPDX-License-Identifier: Apache-2.0 -.. _cli_reference_token: - :tocdepth: 3 +.. _cli_reference_token: + ================================================ osmo token ================================================ diff --git a/docs/user_guide/appendix/cli/cli_user.rst b/docs/user_guide/appendix/cli/cli_user.rst new file mode 100644 index 000000000..2425419e6 --- /dev/null +++ b/docs/user_guide/appendix/cli/cli_user.rst @@ -0,0 +1,32 @@ +.. + SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + SPDX-License-Identifier: Apache-2.0 + +:tocdepth: 3 + +.. _cli_reference_user: + +================================================ +osmo user +================================================ + +.. argparse-with-postprocess:: + :module: src.cli.main_parser + :func: create_cli_parser + :prog: osmo + :path: user + :ref-prefix: cli_reference_user + :argument-anchor: diff --git a/docs/user_guide/appendix/cli/cli_version.rst b/docs/user_guide/appendix/cli/cli_version.rst index 1498d641f..0f831ff60 100644 --- a/docs/user_guide/appendix/cli/cli_version.rst +++ b/docs/user_guide/appendix/cli/cli_version.rst @@ -15,10 +15,10 @@ SPDX-License-Identifier: Apache-2.0 -.. _cli_reference_version: - :tocdepth: 3 +.. _cli_reference_version: + ================================================ osmo version ================================================ diff --git a/docs/user_guide/appendix/cli/cli_workflow.rst b/docs/user_guide/appendix/cli/cli_workflow.rst index aa55ee593..90a408cc2 100644 --- a/docs/user_guide/appendix/cli/cli_workflow.rst +++ b/docs/user_guide/appendix/cli/cli_workflow.rst @@ -15,10 +15,10 @@ SPDX-License-Identifier: Apache-2.0 -.. _cli_reference_workflow: - :tocdepth: 3 +.. _cli_reference_workflow: + ================================================ osmo workflow ================================================ diff --git a/docs/user_guide/appendix/cli/index.rst b/docs/user_guide/appendix/cli/index.rst index 404f6acfd..9243a0dde 100644 --- a/docs/user_guide/appendix/cli/index.rst +++ b/docs/user_guide/appendix/cli/index.rst @@ -1,5 +1,5 @@ .. - SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -36,5 +36,6 @@ CLI Reference cli_resource cli_task cli_token + cli_user cli_version cli_workflow diff --git a/docs/user_guide/getting_started/credentials.rst b/docs/user_guide/getting_started/credentials.rst index d3995a2ef..82d120ff0 100644 --- a/docs/user_guide/getting_started/credentials.rst +++ b/docs/user_guide/getting_started/credentials.rst @@ -1,5 +1,5 @@ .. - SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -82,7 +82,7 @@ Registry .. seealso:: Please refer to `Docker Documentation `__ for more information - on username/password and Personal Access Token (PAT) authentication. + on username/password and Access Token authentication. To setup a registry credential for Docker Hub, run the following command: @@ -274,6 +274,138 @@ Another example is to access Weights and Biases (W&B) for logging and tracking y Your registry and data credentials are picked up automatically when you submit a workflow. To specify a generic credential in the workflow, refer to :ref:`workflow_spec_secrets`. +.. _access_tokens: + +Access Tokens +====================== + +Access Tokens (PATs) provide a way to authenticate with OSMO programmatically, +enabling integration with CI/CD pipelines, scripts, and automation tools. + +Overview +-------- + +PATs are tied to your user account and inherit your roles at creation time. When you create +a PAT, it receives either all of your current roles or a subset that you specify. + +.. important:: + + - PAT roles are immutable after creation. To change a token's roles, delete the token and create a new one. + - When a role is removed from your user account, it is automatically removed from all your PATs. + - Store your PAT securely—it is only displayed once at creation time. + +Creating a Access Token +-------------------------------- + +Using the CLI +^^^^^^^^^^^^^ + +1. First, log in to OSMO: + + .. code-block:: bash + + $ osmo login https://osmo.example.com + +2. Create a new token with an expiration date: + + .. code-block:: bash + + $ osmo token set my-token --expires-at 2027-01-01 --description "My automation token" + + The token will be displayed once. Save it securely. + + **Example output:** + + .. code-block:: text + + Note: Save the token in a secure location as it will not be shown again + Access token: osmo_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + + **Specifying Roles:** + + By default, a PAT inherits all of your current roles. You can limit the token to specific + roles using the ``--roles`` (or ``-r``) option: + + .. code-block:: bash + + $ osmo token set my-token --expires-at 2027-01-01 -r osmo-user -r osmo-ml-team + + This creates a token with only the ``osmo-user`` and ``osmo-ml-team`` roles, even if you + have additional roles assigned. You can only assign roles that you currently have. + +3. (Optional) Verify the token was created and check its roles: + + .. code-block:: bash + + $ osmo token list + +Using a Access Token +----------------------------- + +Once you have a PAT, you can use it to authenticate with OSMO. + +CLI Authentication +^^^^^^^^^^^^^^^^^^ + +Log in using the token method: + +.. code-block:: bash + + $ osmo login https://osmo.example.com --method=token --token=osmo_xxxxxxxxxx + +After logging in, all subsequent CLI commands will use this authentication: + +.. code-block:: bash + + $ osmo workflow list + $ osmo workflow submit my-workflow.yaml + +Alternatively, you can store the token in a file and reference it: + +.. code-block:: bash + + # Store token in a file (ensure proper file permissions) + $ echo "osmo_xxxxxxxxxx" > ~/.osmo-token + $ chmod 600 ~/.osmo-token + + # Login using the token file + $ osmo login https://osmo.example.com --method=token --token-file=~/.osmo-token + +.. note:: + + The ``--method=token`` login exchanges your PAT for a short-lived JWT that is used + for subsequent API calls. This JWT is automatically refreshed as needed. + +Best Practices +-------------- + +.. grid:: 2 + :gutter: 3 + + .. grid-item-card:: Set Appropriate Expiration + :class-card: sd-border-1 + + Always set an expiration date appropriate for your use case. For CI/CD pipelines, + consider shorter expiration periods and rotate tokens regularly. + + .. grid-item-card:: Use Descriptive Names + :class-card: sd-border-1 + + Use descriptive token names and descriptions to help identify their purpose + (e.g., ``ci-github-actions``, ``jenkins-prod-pipeline``). + + .. grid-item-card:: Secure Storage + :class-card: sd-border-1 + + Store tokens in secure secret management systems like HashiCorp Vault, + AWS Secrets Manager, or Kubernetes Secrets. + + .. grid-item-card:: Rotate Regularly + :class-card: sd-border-1 + + Periodically rotate tokens by creating a new token and deleting the old one. + This limits the impact of potential token compromise. + .. _credentials_cli: CLI Reference @@ -281,4 +413,5 @@ CLI Reference .. seealso:: - See :ref:`here ` for the full CLI reference for ``osmo credential``. + - See :ref:`cli_reference_credential` for the full CLI reference for ``osmo credential``. + - See :ref:`cli_reference_token` for the full CLI reference for ``osmo token``. diff --git a/docs/user_guide/getting_started/profile.rst b/docs/user_guide/getting_started/profile.rst index d28dc873a..8d8fa4c62 100644 --- a/docs/user_guide/getting_started/profile.rst +++ b/docs/user_guide/getting_started/profile.rst @@ -43,6 +43,9 @@ profile, including bucket and pool defaults. accessible: - my-pool - team-pool + roles: + - osmo-user + - osmo-ml-team .. _profile_default_dataset_bucket: diff --git a/docs/user_guide/index.rst b/docs/user_guide/index.rst index bda8b0851..6ef6a2c15 100644 --- a/docs/user_guide/index.rst +++ b/docs/user_guide/index.rst @@ -1,5 +1,5 @@ .. - SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/projects/PROJ-148-auth-rework/PROJ-148-user-management.md b/projects/PROJ-148-auth-rework/PROJ-148-user-management.md new file mode 100644 index 000000000..230ae8683 --- /dev/null +++ b/projects/PROJ-148-auth-rework/PROJ-148-user-management.md @@ -0,0 +1,1087 @@ + + +# User Management and Role Mapping Design + +**Author**: @RyaliNvidia
+**PIC**: @RyaliNvidia
+**Proposal Issue**: [#148](https://github.com/NVIDIA/OSMO/issues/148) + +## Overview + +This document describes the design for adding user management to OSMO, including a `users` table, `user_roles` and `pat_roles` tables for role assignments, and SCIM-compatible APIs for user management. + +> **Implementation Status**: This design has been implemented. See `migration/6_0_2.sql` for the database migration and `external/src/service/core/auth/auth_service.py` for the API implementation. + +### Motivation + +- **User visibility** — Currently, OSMO has no concept of "users" as first-class entities. Users exist only implicitly through IDP authentication or access token ownership. +- **Centralized role management** — Roles are currently stored as a `TEXT[]` column in the `access_token` table. This makes it difficult to manage role assignments across users and tokens consistently. +- **SCIM readiness** — Many enterprise identity providers support SCIM (System for Cross-domain Identity Management) for automated user provisioning. Designing SCIM-compatible APIs now will ease future integration. +- **Audit and compliance** — Storing users explicitly enables better audit trails for who has access to what. + +### Problem + +The current OSMO authorization model has several gaps: + +1. **No User Table** — Users are identified by their IDP username (extracted from JWT claims), but OSMO doesn't store user records. This means: + - No way to list all users who have ever accessed OSMO + - No way to pre-provision users before their first login + +2. **Roles Embedded in Access Tokens** — The `access_token` table stores roles as a `TEXT[]` column: + ```sql + CREATE TABLE access_token ( + user_name TEXT, + token_name TEXT, + ... + roles TEXT[], -- Roles are embedded here + ... + ); + ``` + This approach has limitations: + - Changing a user's roles doesn't affect existing tokens + - No audit trail for when/who assigned roles + - No support for time-bound role assignments + - Inconsistent with the user-role model in `PROJ-148-direct-idp-integration.md` + +3. **No Unified Role Assignment** — With direct IDP integration (removing Keycloak), we need a single source of truth for role assignments that covers: + - Users (authenticated via IDP or created for programmatic access) + - Future: Groups (for bulk role assignment) + +### Related Documents + +- [PROJ-148-direct-idp-integration.md](./PROJ-148-direct-idp-integration.md) — Describes Role Management APIs and `user_roles` table +- [PROJ-148-resource-action-model.md](./PROJ-148-resource-action-model.md) — Describes the new resource-action permission model + +This document extends those designs with: +- A proper `users` table for storing user records +- A unified `principal_roles` table (replacing the earlier `user_roles` concept) +- SCIM-compatible API design + +--- + +## Table of Contents + +1. [Requirements](#requirements) +2. [Database Schema](#database-schema) +3. [User Management APIs](#user-management-apis) +4. [Role Assignment APIs](#role-assignment-apis) +5. [Role Resolution](#role-resolution) +6. [SCIM Compatibility](#scim-compatibility) +7. [Migration Strategy](#migration-strategy) +8. [Security Considerations](#security-considerations) + +--- + +## Requirements + +| Title | Description | Type | +|-------|-------------|------| +| User table | System shall store user records with unique identifiers and metadata | Functional | +| User-centric roles | Role assignments shall be made to users; PATs inherit roles from their owner | Functional | +| Role assignment metadata | Each role assignment shall track who assigned it, when, and optional expiration | Functional | +| SCIM-compatible user APIs | User APIs shall follow SCIM patterns for create, read, update, delete operations | Functional | +| Just-in-time provisioning | Users authenticating via IDP shall be auto-provisioned on first login | Functional | +| Service account pattern | Service accounts shall be users with PATs for programmatic access | Functional | +| Backward compatibility | Existing access tokens with embedded roles shall continue to work during migration | Functional | +| Role resolution latency | Role lookups shall complete in <5ms at p99 (cached) | KPI | + +--- + +## Database Schema + +### Users Table + +Stores all user records, including: +- **IDP users** — Authenticated via external identity provider (auto-provisioned on first login) +- **Service accounts** — Created for programmatic access (CI/CD pipelines, automation, etc.) + +Both types of users can have Personal Access Tokens (PATs) for API access. PATs inherit roles from their owning user at creation time. + +```sql +CREATE TABLE users ( + -- Primary identifier (IDP username or service account name) + id TEXT PRIMARY KEY, + + -- Metadata + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + created_by TEXT -- Username of who created this record +); +``` + +> **Note**: The `display_name`, `email`, `external_id`, and `updated_at` fields were removed from the implementation. User identity information is managed by the IDP. + +### User Roles Table + +Maps users to roles. These are the maximum roles a user can have; PATs can use a subset. + +```sql +CREATE TABLE user_roles ( + -- Unique identifier for this assignment (used as FK in pat_roles) + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- User ID (references users.id) + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Role name (references roles.name) + role_name TEXT NOT NULL REFERENCES roles(name) ON DELETE CASCADE, + + -- Audit metadata + assigned_by TEXT NOT NULL, -- Who assigned this role + assigned_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + + -- Unique constraint on user + role combination + UNIQUE (user_id, role_name) +); + +-- Index for fast user lookups (most common query) +CREATE INDEX idx_user_roles_user ON user_roles(user_id); + +-- Index for role-centric queries ("who has this role?") +CREATE INDEX idx_user_roles_role ON user_roles(role_name); +``` + +### PAT Roles Table + +Maps Personal Access Tokens to roles via a foreign key to `user_roles.id`. This design ensures: +- PAT roles can only reference roles the user actually has +- When a user loses a role, all PAT roles referencing it are automatically deleted via CASCADE + +```sql +CREATE TABLE pat_roles ( + -- Token identifier (references access_token) + user_name TEXT NOT NULL, + token_name TEXT NOT NULL, + + -- References the user_role assignment (cascades on delete) + user_role_id UUID NOT NULL REFERENCES user_roles(id) ON DELETE CASCADE, + + -- Audit metadata + assigned_by TEXT NOT NULL, -- Who created the token (may be admin or user) + assigned_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + + -- Composite primary key + PRIMARY KEY (user_name, token_name, user_role_id), + + -- Foreign key to access_token + FOREIGN KEY (user_name, token_name) REFERENCES access_token(user_name, token_name) ON DELETE CASCADE +); + +-- Index for fast token lookups +CREATE INDEX idx_pat_roles_token ON pat_roles(user_name, token_name); + +-- Index for user_role lookups +CREATE INDEX idx_pat_roles_user_role ON pat_roles(user_role_id); +``` + +> **Note**: PAT roles are immutable after creation. To change a PAT's roles, delete the token and create a new one. When a user loses a role, all PATs that had that role automatically lose it as well. + +### Access Token Table + +Stores Personal Access Tokens (PATs). The `roles` and `access_type` columns have been removed; roles are now stored in the `pat_roles` table. Tokens are owned by users and are deleted when the user is deleted. + +```sql +CREATE TABLE access_token ( + user_name TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_name TEXT NOT NULL, + access_token BYTEA, + expires_at TIMESTAMP, + description TEXT, + last_seen_at TIMESTAMP WITH TIME ZONE, -- Last usage timestamp + PRIMARY KEY (user_name, token_name), + CONSTRAINT unique_access_token UNIQUE (access_token) +); +``` + +### Roles Table + +The roles table now includes a `sync_mode` column to control how roles are synchronized with IDP claims. + +```sql +CREATE TABLE roles ( + name TEXT PRIMARY KEY, + description TEXT, + policies JSONB[], + immutable BOOLEAN, + sync_mode TEXT NOT NULL DEFAULT 'import' -- 'force', 'import', or 'ignore' +); +``` + +**Sync Mode Values:** +- `force` — Always apply this role to all users (e.g., for system roles) +- `import` — Role is imported from IDP claims or `user_roles` table (default) +- `ignore` — Ignore this role in IDP sync (role is managed manually) + +### Role External Mappings Table + +Maps external IDP role names to OSMO roles. This enables flexible role mapping when IDP role names differ from OSMO role names. + +```sql +CREATE TABLE role_external_mappings ( + -- OSMO role name (references roles.name) + role_name TEXT NOT NULL REFERENCES roles(name) ON DELETE CASCADE, + + -- External role name from IDP (e.g., from x-osmo-roles header) + external_role TEXT NOT NULL, + + -- Composite primary key ensures unique mappings + PRIMARY KEY (role_name, external_role) +); + +-- Index for fast lookups by external role name (used during IDP role resolution) +CREATE INDEX idx_role_external_mappings_external_role ON role_external_mappings (external_role); +``` + +**Key Characteristics:** + +1. **Many-to-many mapping** — Multiple external roles can map to the same OSMO role, and one external role can map to multiple OSMO roles +2. **Default mapping** — During migration, each existing role automatically gets a 1:1 mapping (role name → same external role name) +3. **Flexible IDP integration** — Supports scenarios where IDP uses different naming conventions + +**Example Use Cases:** + +| External Role (IDP) | OSMO Role | Use Case | +|---------------------|-----------|----------| +| `LDAP_ML_TEAM` | `osmo-ml-team` | Map LDAP group name to OSMO role | +| `ad-developers` | `osmo-user` | Map AD group to OSMO role | +| `ad-developers` | `osmo-dev-team` | Same external role grants multiple OSMO roles | +| `senior-engineer` | `osmo-admin` | Map job title to admin role | +| `junior-engineer` | `osmo-admin` | Multiple external roles can grant admin | + +### Related Tables with Foreign Keys + +The following tables reference the `users` table: + +```sql +-- Profile table (user preferences) +CREATE TABLE profile ( + user_name TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + slack_notification BOOLEAN, + email_notification BOOLEAN, + bucket TEXT, + pool TEXT, + PRIMARY KEY (user_name) +); + +-- User encryption keys table +CREATE TABLE ueks ( + uid TEXT REFERENCES users(id) ON DELETE CASCADE, + keys HSTORE, + PRIMARY KEY (uid) +); +``` + +### Schema Relationship Diagram + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ DATABASE SCHEMA RELATIONSHIPS │ +└─────────────────────────────────────────────────────────────────────────────┘ + + ┌──────────────┐ + │ roles │ + ├──────────────┤ + │ name (PK) │ + │ description │ + │ policies │ + │ immutable │ + │ sync_mode │ + └──────────────┘ + ▲ + ┌────────────────────┼────────────────────┐ + │ │ │ + ┌─────────────────────────┐ ┌──────────────┐ ┌──────────────┐ + │ role_external_mappings │ │ user_roles │ │ pat_roles │ + ├─────────────────────────┤ ├──────────────┤ ├──────────────┤ + │ role_name (PK,FK) │ │ id (PK,UUID) │◄────│user_role_id │ + │ external_role (PK) │ │ user_id (FK) │ │ (FK) │ + └─────────────────────────┘ │ role_name(FK)│ │ user_name(FK)│ + ▲ │ assigned_by │ │ token_name │ + │ │ assigned_at │ │ assigned_by │ + │ └──────────────┘ │ assigned_at │ + (IDP role lookup) ▲ └──────────────┘ + │ ▲ + │ │ + │ ┌──────────────────┐ + │ │ access_token │ + │ ├──────────────────┤ + │ │ user_name(PK,FK) │ + │ │ token_name (PK) │ + │ │ access_token │ + │ │ expires_at │ + │ │ description │ + │ │ last_seen_at │ + │ └──────────────────┘ + │ │ + │ │ N:1 (FK) + │ ▼ + ┌──────────────────────┴────────────────────────────────┐ + │ users │ + ├───────────────────────────────────────────────────────┤ + │ id (PK) │ + │ created_at, created_by │ + └───────────────────────────────────────────────────────┘ + ▲ ▲ + │ 1:1 │ 1:1 + │ │ + ┌──────────────┐ ┌──────────────┐ + │ profile │ │ ueks │ + ├──────────────┤ ├──────────────┤ + │ user_name(FK)│ │ uid (FK) │ + │ ... │ │ keys │ + └──────────────┘ └──────────────┘ + + - PAT roles reference user_roles.id via FK (ON DELETE CASCADE) + - When a user loses a role, all PAT roles referencing it are auto-deleted + - access_token.user_name references users.id (ON DELETE CASCADE) + - Profile and ueks have 1:1 foreign key to users (ON DELETE CASCADE) + - user_roles has N:1 foreign key to users + - role_external_mappings maps external IDP roles to OSMO roles (many-to-many) +``` + +### Service Account Workflow + +Service accounts (for CI/CD pipelines, automation, etc.) follow the same pattern as regular users. PATs automatically inherit all roles from the user at creation time. + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ SERVICE ACCOUNT CREATION FLOW │ +└─────────────────────────────────────────────────────────────────────────────┘ + + Step 1: Create a user for the service account + ────────────────────────────────────────────── + POST /api/auth/user + { + "id": "ci-pipeline@myorg.local", + "roles": ["osmo-user", "osmo-ml-team"] // Optional: assign roles during creation + } + + Step 2: (Optional) Assign additional roles to the service account user + ─────────────────────────────────────────────────────────────────────── + POST /api/auth/user/ci-pipeline@myorg.local/roles + { + "role_name": "osmo-admin" + } + + Step 3: Create a PAT for programmatic access (admin creates for user) + ───────────────────────────────────────────────────────────────────── + POST /api/auth/user/ci-pipeline@myorg.local/access_token/ci-token + ?expires_at=2027-01-01&description=CI%20Pipeline%20Token + + The PAT automatically inherits all of the user's current roles. + + Usage: Use the PAT for API authentication + ───────────────────────────────────────── + curl -H "Authorization: Bearer " https://osmo.example.com/api/workflow +``` + +**Benefits of this approach:** + +1. **Simplified role management** — PATs inherit user roles automatically at creation +2. **Unified user model** — Service accounts are just users, simplifying role management +3. **Centralized user roles** — User roles define the permissions for all PATs +4. **Admin-created PATs** — Admins can create PATs for any user via the admin API +5. **Easy rotation** — Create new PAT (inherits current roles), delete old PAT + +> **Note**: PAT roles cannot be added after creation. However, when a role is removed from a user, it is also automatically removed from all of that user's PATs. This ensures PATs cannot have roles that the user no longer has. + +--- + +## User Management APIs + +The User Management APIs follow SCIM-inspired patterns. All endpoints are under the `/api/auth/` prefix. + +### API Summary + +| Endpoint | Method | Action Required | Description | +|----------|--------|-----------------|-------------| +| `/api/auth/user` | GET | `user:List` | List users with filtering | +| `/api/auth/user` | POST | `user:Create` | Create a new user | +| `/api/auth/user/{id}` | GET | `user:Read` | Get user details with roles | +| `/api/auth/user/{id}` | DELETE | `user:Delete` | Delete user | + +### List Users + +``` +GET /api/auth/user +``` + +**Query Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `start_index` | integer | Pagination start (1-based, default: 1) | +| `count` | integer | Results per page (default: 100, max: 1000) | +| `id_prefix` | string | Filter users whose ID starts with this prefix | +| `roles` | list | Filter users who have ANY of these roles (use multiple params: `?roles=admin&roles=user`) | + +**Response:** +```json +{ + "total_results": 42, + "start_index": 1, + "items_per_page": 100, + "users": [ + { + "id": "user@example.com", + "created_at": "2026-01-15T10:30:00Z", + "created_by": "admin@example.com" + } + ] +} +``` + +### Create User + +``` +POST /api/auth/user +``` + +**Request Body:** +```json +{ + "id": "newuser@example.com", + "roles": ["osmo-user"] +} +``` + +The `roles` field is optional and provides a convenient way to assign initial roles during user creation. + +**Response:** `201 Created` +```json +{ + "id": "newuser@example.com", + "created_at": "2026-02-03T14:30:00Z", + "created_by": "admin@example.com" +} +``` + +### Get User + +``` +GET /api/auth/user/{id} +``` + +**Response:** +```json +{ + "id": "user@example.com", + "created_at": "2026-01-15T10:30:00Z", + "created_by": "system", + "roles": [ + { + "role_name": "osmo-user", + "assigned_by": "admin@example.com", + "assigned_at": "2026-01-15T10:30:00Z" + } + ] +} +``` + +### Delete User + +``` +DELETE /api/auth/user/{id} +``` + +Deletes the user and all associated data (role assignments, PATs, profile, encryption keys) via cascading foreign keys. Does NOT delete: +- Workflows submitted by the user +- Audit logs + +**Response:** `204 No Content` + +--- + +## Role Assignment APIs + +These APIs manage the `user_roles` table. All endpoints are under the `/api/auth/` prefix. + +### API Summary + +| Endpoint | Method | Action | Description | +|----------|--------|--------|-------------| +| `/api/auth/user/{id}/roles` | GET | `role:Read` | List user's roles | +| `/api/auth/user/{id}/roles` | POST | `role:Manage` | Assign role to user | +| `/api/auth/user/{id}/roles/{role}` | DELETE | `role:Manage` | Remove role from user | +| `/api/auth/roles/{name}/users` | GET | `role:Read` | List users with role | +| `/api/auth/roles/{name}/users` | POST | `role:Manage` | Bulk assign role to users | + +### List User Roles + +``` +GET /api/auth/user/{id}/roles +``` + +**Response:** +```json +{ + "user_id": "user@example.com", + "roles": [ + { + "role_name": "osmo-user", + "assigned_by": "admin@example.com", + "assigned_at": "2026-01-15T10:30:00Z" + }, + { + "role_name": "osmo-ml-team", + "assigned_by": "admin@example.com", + "assigned_at": "2026-01-20T09:00:00Z" + } + ] +} +``` + +### Assign Role to User + +``` +POST /api/auth/user/{id}/roles +``` + +**Request Body:** +```json +{ + "role_name": "osmo-ml-team" +} +``` + +**Response:** `201 Created` +```json +{ + "user_id": "user@example.com", + "role_name": "osmo-ml-team", + "assigned_by": "admin@example.com", + "assigned_at": "2026-02-03T14:30:00Z" +} +``` + +**Idempotent behavior:** If the role is already assigned, the operation succeeds and returns the existing assignment. + +### Remove Role from User + +``` +DELETE /api/auth/user/{id}/roles/{role_name} +``` + +Removes the role from the user **and from all PATs owned by that user**. This ensures PATs cannot have roles that the user no longer has. + +**Response:** `204 No Content` + +### List Users with Role + +``` +GET /api/auth/roles/{role_name}/users +``` + +**Response:** +```json +{ + "role_name": "osmo-ml-team", + "users": [ + { + "user_id": "user1@example.com", + "assigned_by": "admin@example.com", + "assigned_at": "2026-01-15T10:30:00Z" + }, + { + "user_id": "ci-pipeline@myorg.local", + "assigned_by": "admin@example.com", + "assigned_at": "2026-01-20T09:00:00Z" + } + ] +} +``` + +### Bulk Role Assignment + +``` +POST /api/auth/roles/{role_name}/users +``` + +**Request Body:** +```json +{ + "user_ids": [ + "user1@example.com", + "user2@example.com", + "ci-pipeline@myorg.local" + ] +} +``` + +**Response:** +```json +{ + "role_name": "osmo-ml-team", + "assigned": ["user1@example.com", "ci-pipeline@myorg.local"], + "already_assigned": ["user2@example.com"], + "failed": [] +} +``` + +--- + +## Access Token (PAT) APIs + +These APIs manage Personal Access Tokens. All endpoints are under the `/api/auth/` prefix. + +### API Summary + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/auth/access_token/{token_name}` | POST | Create a PAT for the authenticated user | +| `/api/auth/access_token/{token_name}` | DELETE | Delete a PAT | +| `/api/auth/access_token` | GET | List all PATs for the authenticated user | +| `/api/auth/user/{user_id}/access_token/{token_name}` | POST | **Admin API**: Create a PAT for any user | +| `/api/auth/user/{user_id}/access_token` | GET | **Admin API**: List all PATs for any user | +| `/api/auth/user/{user_id}/access_token/{token_name}` | DELETE | **Admin API**: Delete any user's PAT | + +### Create Access Token (Self) + +``` +POST /api/auth/access_token/{token_name}?expires_at=YYYY-MM-DD&description=...&roles=role1&roles=role2 +``` + +Creates a PAT for the authenticated user. + +**Role Assignment Behavior:** +- If `roles` is not specified: The token inherits **all** of the user's current roles from `user_roles` +- If `roles` is specified: The token is assigned only the specified roles (must be a subset of the user's roles) +- At least one role must be assigned to the token +- Role validation is atomic — if any specified role is not assigned to the user, the entire operation fails + +**Query Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `expires_at` | string | Yes | Expiration date in YYYY-MM-DD format | +| `description` | string | No | Optional description for the token | +| `roles` | list | No | Roles to assign (multiple params: `?roles=admin&roles=user`). If omitted, inherits all user roles. | + +**Response:** The generated access token string (store securely, shown only once) + +**Error Cases:** +- `400 Bad Request` — If any specified role is not assigned to the user +- `400 Bad Request` — If the resulting role list is empty + +### Create Access Token (Admin) + +``` +POST /api/auth/user/{user_id}/access_token/{token_name}?expires_at=YYYY-MM-DD&description=... +``` + +Admin API to create a PAT for any user. The token inherits the target user's roles, and the `assigned_by` field records the admin who created the token. + +**Path Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `user_id` | string | The user ID to create the token for | +| `token_name` | string | Name for the access token | + +**Query Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `expires_at` | string | Yes | Expiration date in YYYY-MM-DD format | +| `description` | string | No | Optional description for the token | +| `roles` | list | No | Roles to assign (multiple params: `?roles=admin&roles=user`). If omitted, inherits all target user's roles. | + +**Response:** The generated access token string + +### Delete Access Token (Self) + +``` +DELETE /api/auth/access_token/{token_name} +``` + +Deletes a PAT owned by the authenticated user. + +**Response:** `204 No Content` + +### Delete Access Token (Admin) + +``` +DELETE /api/auth/user/{user_id}/access_token/{token_name} +``` + +Admin API to delete any user's PAT. + +**Path Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `user_id` | string | The user ID who owns the token | +| `token_name` | string | Name of the access token to delete | + +**Response:** `204 No Content` + +### List Access Tokens (Self) + +``` +GET /api/auth/access_token +``` + +Lists all PATs owned by the authenticated user. + +**Response:** +```json +[ + { + "user_name": "user@example.com", + "token_name": "my-token", + "expires_at": "2027-01-01T00:00:00Z", + "description": "CI Pipeline Token" + } +] +``` + +### List Access Tokens (Admin) + +``` +GET /api/auth/user/{user_id}/access_token +``` + +Admin API to list all PATs for any user. + +**Path Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `user_id` | string | The user ID to list tokens for | + +**Response:** +```json +[ + { + "user_name": "target-user@example.com", + "token_name": "user-token", + "expires_at": "2027-01-01T00:00:00Z", + "description": "User's token" + } +] +``` + +> **Note**: The actual access token value is never returned after creation for security reasons. + +--- + +## Role Resolution + +When a request comes in, the authorization middleware resolves roles based on how the user authenticated. + +### Resolution Order + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ ROLE RESOLUTION FLOW │ +└─────────────────────────────────────────────────────────────────────────────┘ + + Request with JWT or PAT + │ + ▼ + ┌────────────────────────────────────────────────────────────────────────┐ + │ Step 1: Identify Authentication Method │ + │ │ + │ JWT Token (browser/IDP flow): │ + │ auth_type = JWT │ + │ user_id = user_claim (e.g., preferred_username, email) │ + │ │ + │ PAT (programmatic access): │ + │ auth_type = PAT │ + │ user_name = access_token.user_name │ + │ token_name = access_token.token_name │ + └────────────────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌────────────────────────────────────────────────────────────────────────┐ + │ Step 2: Collect Roles Based on Auth Type │ + │ │ + │ IF auth_type = JWT: │ + │ Source 1: IDP Claims (mapped via role_external_mappings) │ + │ external_roles = x-osmo-roles header OR token.groups/roles │ + │ roles += SELECT role_name FROM role_external_mappings │ + │ WHERE external_role IN (external_roles) │ + │ AND sync_mode != 'ignore' │ + │ │ + │ Source 2: user_roles table │ + │ SELECT role_name FROM user_roles WHERE user_id = ? │ + │ │ + │ Source 3: Forced roles (sync_mode = 'force') │ + │ SELECT name FROM roles WHERE sync_mode = 'force' │ + │ │ + │ IF auth_type = PAT: │ + │ Source: pat_roles joined with user_roles │ + │ SELECT ur.role_name FROM pat_roles pr │ + │ JOIN user_roles ur ON pr.user_role_id = ur.id │ + │ WHERE pr.user_name = ? AND pr.token_name = ? │ + │ │ + │ Source 4: Default roles (based on authentication status) │ + │ Unauthenticated: [osmo-default] │ + │ Authenticated: [osmo-user] (if configured) │ + └────────────────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌────────────────────────────────────────────────────────────────────────┐ + │ Step 3: Deduplicate and Return │ + │ │ + │ roles = unique(all sources) │ + └────────────────────────────────────────────────────────────────────────┘ +``` + +**Key Differences:** +- **JWT users** get roles from `user_roles` (all roles assigned to the user) plus IDP roles mapped via `role_external_mappings` +- **PAT users** get roles from `pat_roles` (subset of user's roles assigned to this specific token) +- **External role mapping** translates IDP role names to OSMO role names, enabling integration with IDPs that use different naming conventions + +--- + +## SCIM Compatibility + +The API design follows SCIM 2.0 patterns to enable future integration with SCIM-enabled identity providers. + +### SCIM Mapping + +| OSMO Concept | SCIM Resource | Notes | +|--------------|---------------|-------| +| User | `/Users` | Core user resource (includes service accounts) | +| Role Assignment | Extension or `/Groups` | Can be modeled as group membership | + +### Future SCIM Endpoint Structure + +When SCIM is implemented, it will be available under `/scim/v2/`: + +``` +/scim/v2/Users - SCIM User operations +/scim/v2/Groups - SCIM Group operations (role assignments) +/scim/v2/ServiceProviderConfig +/scim/v2/ResourceTypes +/scim/v2/Schemas +``` + +### SCIM User Schema Mapping + +```json +{ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "user@example.com", + "userName": "user@example.com", + "active": true, + "meta": { + "resourceType": "User", + "created": "2026-01-15T10:30:00Z" + } +} +``` + +> **Note**: The `active` field is always `true` for existing users. OSMO does not support deactivating users; to revoke access, delete the user. Fields like `displayName`, `emails`, `externalId`, and `lastModified` are not stored in OSMO as user identity information is managed by the IDP. + +### Design Decisions for SCIM Compatibility + +1. **User ID as username** — SCIM uses `id` as the primary identifier. By using the IDP username as our `users.id`, we maintain consistency. + +2. **Idempotent operations** — SCIM requires idempotent PUT operations, which our API supports. + +3. **Filtering** — The `id_prefix` and `roles` query parameters enable common filtering operations. + +4. **Active field** — SCIM's `active` boolean will always be `true` for existing users. To deactivate a user, delete them from OSMO. + +--- + +### Breaking Changes + +1. **SERVICE tokens removed** — All SERVICE type access tokens are deleted during migration +2. **Token roles column removed** — Roles are now stored in the `pat_roles` table +3. **access_type column removed** — All tokens are now Personal Access Tokens (PATs) +4. **Foreign key constraints** — Profile and ueks entries without a corresponding user are deleted + +--- + +## Security Considerations + +### Authorization for User Management + +| Action | Required Permission | Notes | +|--------|---------------------|-------| +| List users | `user:List` | Included in `osmo-admin` | +| Create user | `user:Create` | Included in `osmo-admin` | +| Read user | `user:Read` | Users can read their own record | +| Delete user | `user:Delete` | Included in `osmo-admin` | +| Manage roles | `role:Manage` | Fine-grained by resource pattern | + +### Self-Service Restrictions + +Users can perform limited operations on their own tokens: + +- **Allowed**: Create PATs for themselves, list own PATs, delete own PATs +- **Not allowed**: Assign roles, view other users, delete own account, create PATs for other users + +### Audit Logging + +All user management and role assignment operations should be logged: + +```json +{ + "timestamp": "2026-02-03T14:30:00Z", + "actor": "admin@example.com", + "action": "user:Create", + "resource": "user/newuser@example.com", + "details": { + "display_name": "New User", + "roles_assigned": ["osmo-user"] + } +} +``` + +--- + +## Open Questions + +1. **Should we support user groups?** + - Groups would enable bulk role assignment + - Implementation deferred to future work + - Schema can be extended to add a `groups` table and `group_roles` table + +2. ~~**How to handle existing SERVICE access tokens during migration?**~~ + - **RESOLVED**: SERVICE tokens are deleted during migration. Service accounts should be recreated as regular users with PATs. + +--- + +## Action Registry Additions + +Add the following actions to the authorization middleware: + +| Action | Path Pattern | Methods | +|--------|--------------|---------| +| `user:List` | `/api/auth/user` | GET | +| `user:Create` | `/api/auth/user` | POST | +| `user:Read` | `/api/auth/user/*` | GET | +| `user:Delete` | `/api/auth/user/*` | DELETE | +| `role:Read` | `/api/auth/user/*/roles` | GET | +| `role:Read` | `/api/auth/roles/*/users` | GET | +| `role:Manage` | `/api/auth/user/*/roles` | POST | +| `role:Manage` | `/api/auth/user/*/roles/*` | DELETE | +| `role:Manage` | `/api/auth/roles/*/users` | POST | +| `token:Create` | `/api/auth/access_token/*` | POST | +| `token:Delete` | `/api/auth/access_token/*` | DELETE | +| `token:List` | `/api/auth/access_token` | GET | +| `token:AdminCreate` | `/api/auth/user/*/access_token/*` | POST | + +--- + +## Implementation Status + +This design has been implemented: + +- [x] Database migration (`migration/6_0_2.sql`) +- [x] Users table with foreign key relationships +- [x] User roles table +- [x] PAT roles table +- [x] Access token schema updates (removed `access_type`, `roles`; added `last_seen_at`) +- [x] Roles table `sync_mode` column +- [x] Role external mappings table (IDP role name → OSMO role mapping) +- [x] User Management APIs (`/api/auth/user/*`) +- [x] Role Assignment APIs (`/api/auth/user/*/roles`, `/api/auth/roles/*/users`) +- [x] Access Token APIs (`/api/auth/access_token/*`) +- [x] Admin API for creating PATs for any user +- [x] Authorization middleware role synchronization with sync_mode + +## Next Steps + +1. **Add tests** for all new APIs +2. **Document** user-facing APIs and migration guide + +## Implementation Notes + +### Just-in-Time User Provisioning + +Users are automatically provisioned on first access via the `upsert_user` function in `postgres.py`. This function creates a new user record if the user doesn't exist. + +This is called from the `AccessControlMiddleware`, ensuring users are created when they first access the system. + +### Role Synchronization from IDP + +The `AccessControlMiddleware` synchronizes user roles from IDP headers on each request via the `sync_user_roles` function. The process involves two steps: + +#### Step 1: External Role Mapping + +When roles arrive from the IDP (via the `x-osmo-roles` header or JWT claims), they are first translated to OSMO roles using the `role_external_mappings` table: + +``` +IDP sends: ["LDAP_ML_TEAM", "ad-developers"] + │ + ▼ + ┌─────────────────────────────────────┐ + │ role_external_mappings │ + ├─────────────────────────────────────┤ + │ LDAP_ML_TEAM → osmo-ml-team │ + │ ad-developers → osmo-user │ + │ ad-developers → osmo-dev-team │ + └─────────────────────────────────────┘ + │ + ▼ +Resolved OSMO roles: ["osmo-ml-team", "osmo-user", "osmo-dev-team"] +``` + +This mapping enables: +- **Different naming conventions** — Map LDAP/AD group names to OSMO role names +- **Role consolidation** — Map multiple IDP roles to a single OSMO role +- **Role expansion** — Map one IDP role to multiple OSMO roles + +#### Step 2: Sync Mode Application + +After mapping, the behavior depends on each role's `sync_mode`: + +| Sync Mode | IDP has role | User has role | Action | +|-----------|--------------|---------------|--------| +| `ignore` | - | - | No action (role is managed manually) | +| `import` | Yes | No | Add role to user | +| `import` | No | Yes | No action (keep existing role) | +| `force` | Yes | No | Add role to user | +| `force` | No | Yes | **Remove role from user** | + +**Key Behaviors:** +- **ignore**: The role is never modified by IDP sync. Use this for manually assigned roles. +- **import**: Roles are added from IDP but never removed. Useful for accumulating roles from different sources. +- **force**: The user's roles exactly match what the IDP provides. If the IDP stops providing a role, it is removed. + +**Example:** If a role `osmo-team-lead` has `sync_mode = 'force'`, and a user logs in without that role in their IDP claims (after external mapping), the role will be removed from their `user_roles` mapping. + +#### Default External Mappings + +During migration, each existing role automatically gets a 1:1 mapping where the external role name equals the OSMO role name. This ensures backward compatibility — if your IDP already sends role names that match OSMO role names, no additional configuration is needed. + +### CLI Commands + +The `osmo user` CLI provides the following commands: + +- `osmo user list` — List all users with optional filtering +- `osmo user create ` — Create a new user with optional roles +- `osmo user get ` — Get user details including roles +- `osmo user update ` — Add or remove roles from a user (uses role assignment APIs, not PUT/PATCH) +- `osmo user delete ` — Delete a user + +The `osmo token` CLI has been updated: +- Removed service token functionality +- Added `--user` flag for admin operations on other users' tokens + +### Role Removal Cascading + +When a role is removed from a user (via CLI or API), the role is also automatically removed from all PATs owned by that user. This ensures that: +- PATs cannot retain roles that the user no longer has +- Role revocation is immediate and complete +- No manual cleanup of PAT roles is required diff --git a/src/cli/BUILD b/src/cli/BUILD index fcc38f232..5d77052a7 100755 --- a/src/cli/BUILD +++ b/src/cli/BUILD @@ -43,6 +43,7 @@ osmo_py_library( "profile.py", "resources.py", "task.py", + "user.py", "version.py", "workflow.py", ], diff --git a/src/cli/access_token.py b/src/cli/access_token.py index e5831ae5a..832b5d66c 100644 --- a/src/cli/access_token.py +++ b/src/cli/access_token.py @@ -1,5 +1,5 @@ """ -SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # pylint: disable=line-too-long Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -26,22 +26,25 @@ def setup_parser(parser: argparse._SubParsersAction): """ - Configures parser to show basic pool information. + Configures parser to manage access tokens. Args: parser: The parser to be configured. """ token_parser = parser.add_parser('token', - help='Set and delete access tokens.') + help='Manage personal access tokens.') subparsers = token_parser.add_subparsers(dest='command') subparsers.required = True set_parser = subparsers.add_parser( 'set', - help='Set a token for the current user.', - description=(''), - epilog='Ex. osmo token set my-token --expires-at 2026-05-01 ' - '--description "My token description"', + help='Create a new access token.', + description='Create a personal access token for yourself or another user (admin only).', + epilog='Ex. osmo token set my-token --expires-at 2026-05-01\n' + 'Ex. osmo token set my-token -e 2026-05-01 -d "My token description"\n' + 'Ex. osmo token set my-token -r role1 -r role2\n' + 'Ex. osmo token set service-token --user service-account@example.com ' + '--roles osmo-backend', formatter_class=argparse.RawDescriptionHelpFormatter) set_parser.add_argument('name', help='Name of the token.') @@ -49,14 +52,17 @@ def setup_parser(parser: argparse._SubParsersAction): default=(datetime.datetime.utcnow() + datetime.timedelta(days=31))\ .strftime('%Y-%m-%d'), type=validation.date_str, - help='Expiration date of the token. The date is based on UTC time. ' - 'Format: YYYY-MM-DD') + help='Expiration date of the token (UTC). Format: YYYY-MM-DD. ' + 'Default: 31 days from now.') set_parser.add_argument('--description', '-d', help='Description of the token.') - set_parser.add_argument('--service', '-s', action='store_true', - help='Create a service token.') - set_parser.add_argument('--roles', '-r', nargs='+', - help='Roles for the token. Only applicable for service tokens.') + set_parser.add_argument('--user', '-u', + help='Create token for a specific user (admin only). ' + 'By default, creates token for the current user.') + set_parser.add_argument('--roles', '-r', + action='append', + help='Role to assign to the token. Can be specified multiple times. ' + 'If not specified, inherits all of the user\'s current roles.') set_parser.add_argument('--format-type', '-t', choices=('json', 'text'), default='text', help='Specify the output format type (Default text).') @@ -64,31 +70,49 @@ def setup_parser(parser: argparse._SubParsersAction): delete_parser = subparsers.add_parser( 'delete', - help='Delete a token for the current user.', - description=(''), - epilog='Ex. osmo token delete my-token', + help='Delete an access token.', + description='Delete an access token for yourself or another user (admin only).', + epilog='Ex. osmo token delete my-token\n' + 'Ex. osmo token delete old-token --user other-user@example.com', formatter_class=argparse.RawDescriptionHelpFormatter) delete_parser.add_argument('name', - help='Name of the token.') - delete_parser.add_argument('--service', '-s', action='store_true', - help='Delete a service token.') + help='Name of the token to delete.') + delete_parser.add_argument('--user', '-u', + help='Delete token for a specific user (admin only). ' + 'By default, deletes token for the current user.') delete_parser.set_defaults(func=_delete_token) list_parser = subparsers.add_parser( 'list', - help='List all tokens for the current user.', - description=(''), - epilog='Ex. osmo token list', + help='List all access tokens.', + description='List access tokens for yourself or another user (admin only).', + epilog='Ex. osmo token list\n' + 'Ex. osmo token list --user service-account@example.com', formatter_class=argparse.RawDescriptionHelpFormatter) - list_parser.add_argument('--service', '-s', action='store_true', - help='List all service tokens.') + list_parser.add_argument('--user', '-u', + help='List tokens for a specific user (admin only). ' + 'By default, lists tokens for the current user.') list_parser.add_argument('--format-type', '-t', choices=('json', 'text'), default='text', help='Specify the output format type (Default text).') list_parser.set_defaults(func=_list_tokens) + roles_parser = subparsers.add_parser( + 'roles', + help='List roles assigned to a token.', + description='List all roles assigned to an access token.', + epilog='Ex. osmo token roles my-token', + formatter_class=argparse.RawDescriptionHelpFormatter) + roles_parser.add_argument('name', + help='Name of the token.') + roles_parser.add_argument('--format-type', '-t', + choices=('json', 'text'), default='text', + help='Specify the output format type (Default text).') + roles_parser.set_defaults(func=_list_token_roles) + def _set_token(service_client: client.ServiceClient, args: argparse.Namespace): + """Create an access token.""" if not re.fullmatch(common.TOKEN_NAME_REGEX, args.name): raise osmo_errors.OSMOUserError( f'Token name {args.name} must match regex {common.TOKEN_NAME_REGEX}') @@ -96,16 +120,16 @@ def _set_token(service_client: client.ServiceClient, args: argparse.Namespace): params = {'expires_at': args.expires_at} if args.description: params['description'] = args.description - - path = f'api/auth/access_token/user/{args.name}' - if args.service: - path = f'api/auth/access_token/service/{args.name}' - - if not args.roles: - raise osmo_errors.OSMOUserError('Roles are required for service tokens.') + if args.roles: params['roles'] = args.roles - elif args.roles: - raise osmo_errors.OSMOUserError('Roles are not supported for personal tokens.') + + # Determine the API path based on whether a user is specified + if args.user: + # Admin API: create token for a specific user + path = f'api/auth/user/{args.user}/access_token/{args.name}' + else: + # Default: create token for the current user + path = f'api/auth/access_token/{args.name}' result = service_client.request(client.RequestMethod.POST, path, payload=None, params=params) @@ -114,40 +138,86 @@ def _set_token(service_client: client.ServiceClient, args: argparse.Namespace): else: print('Note: Save the token in a secure location as it will not be shown again') print(f'Access token: {result}') + if args.user: + print(f'Created for user: {args.user}') + if args.roles: + print(f'Roles: {", ".join(args.roles)}') def _delete_token(service_client: client.ServiceClient, args: argparse.Namespace): - path = f'api/auth/access_token/user/{args.name}' - if args.service: - path = f'api/auth/access_token/service/{args.name}' + """Delete an access token.""" + if args.user: + # Admin API: delete token for a specific user + path = f'api/auth/user/{args.user}/access_token/{args.name}' + else: + # Default: delete token for the current user + path = f'api/auth/access_token/{args.name}' service_client.request(client.RequestMethod.DELETE, path, payload=None, params=None) - print(f'Access token {args.name} deleted') + if args.user: + print(f'Access token {args.name} deleted for user {args.user}') + else: + print(f'Access token {args.name} deleted') def _list_tokens(service_client: client.ServiceClient, args: argparse.Namespace): - path = 'api/auth/access_token/user' - if args.service: - path = 'api/auth/access_token/service' + """List access tokens.""" + if args.user: + # Admin API: list tokens for a specific user + path = f'api/auth/user/{args.user}/access_tokens' + else: + # Default: list tokens for the current user + path = 'api/auth/access_token' result = service_client.request(client.RequestMethod.GET, path) + if not result: - print('No tokens found') + if args.user: + print(f'No tokens found for user {args.user}') + else: + print('No tokens found') return if args.format_type == 'json': - print(json.dumps(result, indent=2)) + print(json.dumps(result, indent=2, default=str)) else: - collection_header = ['Name', 'Description', 'Roles', 'Active', 'Expires At (UTC)'] + if args.user: + print(f'Tokens for user: {args.user}\n') + collection_header = ['Name', 'Description', 'Active', 'Expires At (UTC)', 'Roles'] table = common.osmo_table(header=collection_header) - columns = ['token_name', 'description', 'roles', 'active', 'expires_at'] + columns = ['token_name', 'description', 'active', 'expires_at', 'roles'] for token in result: expire_date = common.convert_str_to_time( token['expires_at'].split('T')[0], '%Y-%m-%d').date() token['expires_at'] = expire_date token['active'] = 'Expired' if expire_date <= datetime.datetime.utcnow().date() \ else 'Active' - token['roles'] = ', '.join(token['roles']) + # Format roles as comma-separated string + roles = token.get('roles', []) + token['roles'] = ', '.join(roles) if roles else '-' table.add_row([token.get(column, '-') for column in columns]) print(f'{table.draw()}\n') + + +def _list_token_roles(service_client: client.ServiceClient, args: argparse.Namespace): + """List roles assigned to a token.""" + path = f'api/auth/access_tokens/{args.name}/roles' + result = service_client.request(client.RequestMethod.GET, path) + + if args.format_type == 'json': + print(json.dumps(result, indent=2, default=str)) + else: + print(f'Token: {result.get("token_name", args.name)}') + print(f'Owner: {result.get("user_name", "-")}') + roles = result.get('roles', []) + if roles: + print('\nRoles:') + for role in roles: + assigned_at = role.get('assigned_at', '-') + if assigned_at and assigned_at != '-': + assigned_at = assigned_at.split('T')[0] + print(f' - {role.get("role_name")} (assigned by {role.get("assigned_by")} ' + f'on {assigned_at})') + else: + print('\nRoles: None') diff --git a/src/cli/login.py b/src/cli/login.py index 952adb8e3..4b9a4d6ef 100644 --- a/src/cli/login.py +++ b/src/cli/login.py @@ -65,8 +65,7 @@ def setup_parser(parser: argparse._SubParsersAction): token_group = login_parser.add_mutually_exclusive_group() token_group.add_argument('--token', help='Token if logging in with credentials.') token_group.add_argument('--token-file', type=argparse.FileType('r'), - help='File containing the refresh token URL, '\ - 'with all parameters appended.').complete = shtab.FILE + help='File containing the refresh token.').complete = shtab.FILE logout_parser = parser.add_parser('logout', help='Remove stored access tokens.') @@ -119,14 +118,16 @@ class UrlValidator(pydantic.BaseModel): raise osmo_errors.OSMOUserError('Must provide password') service_client.login_manager.owner_password_login(url, username, password) - # Login by directly reading the idtoken from a file + # Login by directly reading the refresh token from a file or argument elif args.method == 'token': if args.token_file: - refresh_url = args.token_file.read().strip() + token = args.token_file.read().strip() + args.token_file.close() elif args.token: - refresh_url = login.construct_token_refresh_url(url, args.token) + token = args.token else: raise osmo_errors.OSMOUserError('Must provide token file with --token_file or --token') + refresh_url = login.construct_token_refresh_url(url, token) service_client.login_manager.token_login(url, refresh_url) # For developers, simply send username as a header diff --git a/src/cli/main_parser.py b/src/cli/main_parser.py index ab0b4e749..79484ee16 100644 --- a/src/cli/main_parser.py +++ b/src/cli/main_parser.py @@ -33,6 +33,7 @@ profile, resources, task, + user, workflow, version, ) @@ -53,6 +54,7 @@ resources.setup_parser, profile.setup_parser, pool.setup_parser, + user.setup_parser, config.setup_parser ) diff --git a/src/cli/profile.py b/src/cli/profile.py index 96ccda1e2..3a0e85f23 100644 --- a/src/cli/profile.py +++ b/src/cli/profile.py @@ -1,5 +1,5 @@ """ -SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # pylint: disable=line-too-long Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -96,19 +96,10 @@ def _run_setting_set(service_client: client.ServiceClient, args: argparse.Namesp def _run_setting_list(service_client: client.ServiceClient, args: argparse.Namespace): # pylint: disable=unused-argument result = service_client.request(client.RequestMethod.GET, 'api/profile/settings') - if service_client.login_manager.using_osmo_token(): - access_token = service_client.login_manager.get_access_token() - if access_token: - # Get the roles for the token - roles_result = service_client.request( - client.RequestMethod.GET, - 'api/auth/access_token', - params={'access_token': access_token}) - result['token'] = roles_result if args.format_type == 'text': print('user:') login_dir = client_configs.get_client_config_dir() - login_file = login_dir + '/login.yaml' + login_file = login_dir + '/login.yaml' try: with open(os.path.expanduser(login_file), 'r', encoding='utf-8') as file: login_dict = yaml.safe_load(file.read()) @@ -117,8 +108,6 @@ def _run_setting_list(service_client: client.ServiceClient, args: argparse.Names except FileNotFoundError: pass profile_result = result.get('profile', {}) - pools_result = [f'{common.TAB}- {pool_name}' for pool_name in result.get('pools', [])] - pools_output = '\n'.join(pools_result) email = profile_result.get('username', '') print(f'{common.TAB}email: {email}') print('notifications:\n' @@ -128,15 +117,17 @@ def _run_setting_list(service_client: client.ServiceClient, args: argparse.Names f'{common.TAB}default: {profile_result.get("bucket", "")}\n' 'pool:\n' f'{common.TAB}default: {profile_result.get("pool", "")}\n' - f'{common.TAB}accessible:\n' - f'{pools_output}') - token_result = result.get('token', {}) + f'{common.TAB}accessible:') + for pool_name in result.get('pools', []): + print(f'{common.TAB}{common.TAB}- {pool_name}') + token_result = result.get('token') if token_result: + print(f'token: {token_result.get("name", "")}') expires_at = common.convert_str_to_time(token_result['expires_at'].split('T')[0], '%Y-%m-%d').date() - print(f'token roles:\n' - f'{common.TAB}name: {token_result.get("token_name", "")}\n' - f'{common.TAB}expires_at: {expires_at}\n' - f'{common.TAB}roles: {", ".join(token_result.get("roles", []))}') + print(f'{common.TAB}expires_at: {expires_at}') + print('roles:') + for role in result.get('roles', []): + print(f'{common.TAB}- {role}') else: print(json.dumps(result, indent=2)) diff --git a/src/cli/user.py b/src/cli/user.py new file mode 100644 index 000000000..933dc958f --- /dev/null +++ b/src/cli/user.py @@ -0,0 +1,247 @@ +""" +SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" + +import argparse +import json + +from src.lib.utils import client, common + + +def setup_parser(parser: argparse._SubParsersAction): + """ + Configures parser for user management commands. + + Args: + parser: The parser to be configured. + """ + user_parser = parser.add_parser('user', + help='Manage users and their roles.') + subparsers = user_parser.add_subparsers(dest='command') + subparsers.required = True + + # List users + list_parser = subparsers.add_parser( + 'list', + help='List all users.', + description='List users with optional filtering.', + epilog='Ex. osmo user list\n' + 'Ex. osmo user list --id-prefix service-\n' + 'Ex. osmo user list --roles osmo-admin osmo-user', + formatter_class=argparse.RawDescriptionHelpFormatter) + list_parser.add_argument('--id-prefix', '-p', + help='Filter users whose ID starts with this prefix.') + list_parser.add_argument('--roles', '-r', nargs='+', + help='Filter users who have ANY of these roles.') + list_parser.add_argument('--count', '-c', type=int, default=100, + help='Number of results per page (default: 100).') + list_parser.add_argument('--format-type', '-t', + choices=('json', 'text'), default='text', + help='Specify the output format type (Default text).') + list_parser.set_defaults(func=_list_users) + + # Create user + create_parser = subparsers.add_parser( + 'create', + help='Create a new user.', + description='Create a new user with optional roles.', + epilog='Ex. osmo user create myuser@example.com\n' + 'Ex. osmo user create service-account --roles osmo-user osmo-ml-team', + formatter_class=argparse.RawDescriptionHelpFormatter) + create_parser.add_argument('user_id', + help='User ID (e.g., email or username).') + create_parser.add_argument('--roles', '-r', nargs='+', + help='Initial roles to assign to the user.') + create_parser.add_argument('--format-type', '-t', + choices=('json', 'text'), default='text', + help='Specify the output format type (Default text).') + create_parser.set_defaults(func=_create_user) + + # Update user (add/remove roles) + update_parser = subparsers.add_parser( + 'update', + help='Update a user (add or remove roles).', + description='Add or remove roles from a user.', + epilog='Ex. osmo user update myuser@example.com --add-roles osmo-admin\n' + 'Ex. osmo user update myuser@example.com --remove-roles osmo-ml-team\n' + 'Ex. osmo user update myuser@example.com --add-roles admin --remove-roles guest', + formatter_class=argparse.RawDescriptionHelpFormatter) + update_parser.add_argument('user_id', + help='User ID to update.') + update_parser.add_argument('--add-roles', '-a', nargs='+', + help='Roles to add to the user.') + update_parser.add_argument('--remove-roles', '-r', nargs='+', + help='Roles to remove from the user.') + update_parser.add_argument('--format-type', '-t', + choices=('json', 'text'), default='text', + help='Specify the output format type (Default text).') + update_parser.set_defaults(func=_update_user) + + # Delete user + delete_parser = subparsers.add_parser( + 'delete', + help='Delete a user.', + description='Delete a user and all associated data (tokens, roles, profile).', + epilog='Ex. osmo user delete myuser@example.com', + formatter_class=argparse.RawDescriptionHelpFormatter) + delete_parser.add_argument('user_id', + help='User ID to delete.') + delete_parser.add_argument('--force', '-f', action='store_true', + help='Skip confirmation prompt.') + delete_parser.set_defaults(func=_delete_user) + + # Get user details + get_parser = subparsers.add_parser( + 'get', + help='Get user details.', + description='Get detailed information about a user including their roles.', + epilog='Ex. osmo user get myuser@example.com', + formatter_class=argparse.RawDescriptionHelpFormatter) + get_parser.add_argument('user_id', + help='User ID to get details for.') + get_parser.add_argument('--format-type', '-t', + choices=('json', 'text'), default='text', + help='Specify the output format type (Default text).') + get_parser.set_defaults(func=_get_user) + + +def _list_users(service_client: client.ServiceClient, args: argparse.Namespace): + """List users with optional filtering.""" + params = {'count': args.count} + + if args.id_prefix: + params['id_prefix'] = args.id_prefix + + if args.roles: + params['roles'] = args.roles + + result = service_client.request(client.RequestMethod.GET, 'api/auth/user', + params=params) + + users = result.get('users', []) + if not users: + print('No users found') + return + + if args.format_type == 'json': + print(json.dumps(result, indent=2, default=str)) + else: + print(f'Total users: {result.get("total_results", len(users))}') + print() + collection_header = ['User ID', 'Created At'] + table = common.osmo_table(header=collection_header) + for user in users: + created_at = user.get('created_at', '-') + if created_at and created_at != '-': + created_at = created_at.split('T')[0] + table.add_row([ + user.get('id', '-'), + created_at + ]) + print(f'{table.draw()}\n') + + +def _create_user(service_client: client.ServiceClient, args: argparse.Namespace): + """Create a new user.""" + payload = {'id': args.user_id} + + if args.roles: + payload['roles'] = args.roles + + result = service_client.request(client.RequestMethod.POST, 'api/auth/user', + payload=payload) + + if args.format_type == 'json': + print(json.dumps(result, indent=2, default=str)) + else: + print(f'User created: {result.get("id")}') + if args.roles: + print(f'Roles assigned: {", ".join(args.roles)}') + + +def _update_user(service_client: client.ServiceClient, args: argparse.Namespace): + """Update a user (add/remove roles).""" + user_id = args.user_id + + # Add roles + if args.add_roles: + for role_name in args.add_roles: + payload = {'role_name': role_name} + service_client.request(client.RequestMethod.POST, + f'api/auth/user/{user_id}/roles', + payload=payload) + print(f'Added role: {role_name}') + + # Remove roles + if args.remove_roles: + for role_name in args.remove_roles: + service_client.request(client.RequestMethod.DELETE, + f'api/auth/user/{user_id}/roles/{role_name}') + print(f'Removed role: {role_name}') + + # Get updated user info + if args.format_type == 'json': + result = service_client.request(client.RequestMethod.GET, + f'api/auth/user/{user_id}') + print(json.dumps(result, indent=2, default=str)) + elif not args.add_roles and not args.remove_roles: + print('No updates specified. Use --add-roles or --remove-roles.') + + +def _delete_user(service_client: client.ServiceClient, args: argparse.Namespace): + """Delete a user.""" + user_id = args.user_id + + if not args.force: + confirm = input(f'Are you sure you want to delete user "{user_id}"? ' + 'This will delete all associated tokens, roles, and profile. [y/N]: ') + if confirm.lower() != 'y': + print('Cancelled') + return + + service_client.request(client.RequestMethod.DELETE, f'api/auth/user/{user_id}') + print(f'User deleted: {user_id}') + + +def _get_user(service_client: client.ServiceClient, args: argparse.Namespace): + """Get user details.""" + user_id = args.user_id + + result = service_client.request(client.RequestMethod.GET, + f'api/auth/user/{user_id}') + + if args.format_type == 'json': + print(json.dumps(result, indent=2, default=str)) + else: + print(f'User ID: {result.get("id")}') + created_at = result.get('created_at', '-') + if created_at and created_at != '-': + created_at = created_at.split('T')[0] + print(f'Created At: {created_at}') + print(f'Created By: {result.get("created_by") or "-"}') + + roles = result.get('roles', []) + if roles: + print('\nRoles:') + for role in roles: + assigned_at = role.get('assigned_at', '-') + if assigned_at and assigned_at != '-': + assigned_at = assigned_at.split('T')[0] + print(f' - {role.get("role_name")} (assigned by {role.get("assigned_by")} ' + f'on {assigned_at})') + else: + print('\nRoles: None') diff --git a/src/lib/utils/client.py b/src/lib/utils/client.py index dd461b08a..05c5d000b 100644 --- a/src/lib/utils/client.py +++ b/src/lib/utils/client.py @@ -228,8 +228,8 @@ def dev_login(self, url: str, username: str): self._login_storage = login.dev_login(url, username) self._save_login_info(self._login_storage, welcome=True) - def token_login(self, url: str, refresh_url: str): - self._login_storage = login.token_login(url, refresh_url, self.user_agent) + def token_login(self, url: str, access_token: str): + self._login_storage = login.token_login(url, access_token, self.user_agent) self._save_login_info(self._login_storage, welcome=True) def logout(self): diff --git a/src/lib/utils/login.py b/src/lib/utils/login.py index 0b3e09adb..53ff7dbf7 100644 --- a/src/lib/utils/login.py +++ b/src/lib/utils/login.py @@ -1,5 +1,5 @@ """ -SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # pylint: disable=line-too-long Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -36,6 +36,7 @@ # If developer mode, the header to pass the osmo user in OSMO_USER_HEADER = 'x-osmo-user' OSMO_USER_ROLES = 'x-osmo-roles' +OSMO_TOKEN_NAME_HEADER = 'x-osmo-token-name' # Don't use a token that will expire within the next N seconds EXPIRE_WINDOW = 3 TIMEOUT = 60 @@ -112,10 +113,10 @@ def __str__(self) -> str: class TokenLoginStorage(pydantic.BaseModel): """Stores id_token and refresh_token for logging in""" - refresh_token: str | None + refresh_token: str | None = None id_token: str - refresh_url: str | None - username: str | None + refresh_url: str | None = None + username: str | None = None _id_token_jwt: Jwt | None = pydantic.PrivateAttr(None) @property diff --git a/src/lib/utils/role.py b/src/lib/utils/role.py index f680f4e4f..0f7317f88 100644 --- a/src/lib/utils/role.py +++ b/src/lib/utils/role.py @@ -16,6 +16,7 @@ SPDX-License-Identifier: Apache-2.0 """ +import enum import re from enum import Enum from typing import Any, Dict, List @@ -41,6 +42,19 @@ def validate_semantic_action(value: str) -> str: return value +class SyncMode(str, enum.Enum): + """ + Sync mode for role assignments. + + - FORCE: Always apply this role to all users (e.g., for system roles) + - IMPORT: Role is imported from IDP claims or user_roles table (default) + - IGNORE: Ignore this role in IDP sync (role is managed manually) + """ + FORCE = 'force' + IMPORT = 'import' + IGNORE = 'ignore' + + class PolicyEffect(str, Enum): """Effect of a policy statement: Allow or Deny. Deny takes precedence over Allow.""" @@ -86,17 +100,30 @@ def to_dict(self) -> Dict[str, Any]: class Role(pydantic.BaseModel): - """Single Role Entry.""" + """ + Single Role Entry + + external_roles semantics: + - None: Don't modify external role mappings (preserve existing) + - []: Explicitly clear all external role mappings + - ['role1', 'role2']: Set external role mappings to these values + """ name: str description: str policies: List[RolePolicy] immutable: bool = False + sync_mode: SyncMode = SyncMode.IMPORT + external_roles: List[str] | None = None def to_dict(self) -> Dict[str, Any]: - """Convert to dictionary representation.""" - return { + result = { 'name': self.name, 'description': self.description, 'policies': [policy.to_dict() for policy in self.policies], - 'immutable': self.immutable + 'immutable': self.immutable, + 'sync_mode': self.sync_mode.value, } + # Only include external_roles if explicitly set (not None) + if self.external_roles is not None: + result['external_roles'] = self.external_roles + return result diff --git a/src/service/core/auth/BUILD b/src/service/core/auth/BUILD index 23665450c..6de16b556 100644 --- a/src/service/core/auth/BUILD +++ b/src/service/core/auth/BUILD @@ -28,6 +28,9 @@ osmo_py_library( deps = [ requirement("fastapi"), requirement("pydantic"), + "//src/lib/utils:common", + "//src/lib/utils:login", + "//src/lib/utils:osmo_errors", "//src/utils/connectors", "//src/utils:auth", "//src/utils/job", diff --git a/src/service/core/auth/auth_service.py b/src/service/core/auth/auth_service.py index 04d1ae473..f831f7402 100644 --- a/src/service/core/auth/auth_service.py +++ b/src/service/core/auth/auth_service.py @@ -1,5 +1,5 @@ """ -SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # pylint: disable=line-too-long Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -22,7 +22,7 @@ import fastapi -from src.lib.utils import common, login, osmo_errors +from src.lib.utils import common, osmo_errors from src.utils.job import task as task_lib from src.service.core.auth import objects from src.utils import auth, connectors @@ -33,6 +33,10 @@ ) +# ============================================================================= +# Authentication APIs +# ============================================================================= + @router.get('/api/auth/login', include_in_schema=False) def get_login_info() -> auth.LoginInfo: postgres = connectors.PostgresConnector.get_instance() @@ -47,7 +51,6 @@ def get_keys(): return service_config.service_auth.get_keyset() -@router.get('/api/auth/refresh_token') @router.get('/api/auth/jwt/refresh_token') def get_new_jwt_token(refresh_token: str, workflow_id: str, group_name: str, task_name: str, retry_id: int = 0): @@ -119,115 +122,674 @@ def get_jwt_token_from_access_token(access_token: str): if token.expires_at.date() <= datetime.datetime.utcnow().date(): raise osmo_errors.OSMOUserError('Access Token has expired') + # Get roles from access_token_roles table + roles = objects.AccessToken.get_roles_for_token(postgres, token.user_name, token.token_name) + service_config = postgres.get_service_configs() end_timeout = int(time.time() + common.ACCESS_TOKEN_TIMEOUT) - token = service_config.service_auth.create_idtoken_jwt(end_timeout, token.user_name, - roles=token.roles) - return {'token': token, + jwt_token = service_config.service_auth.create_idtoken_jwt(end_timeout, token.user_name, + roles=roles, + token_name=token.token_name) + return {'token': jwt_token, 'expires_at': end_timeout, 'error': None} -@router.get('/api/auth/access_token') -def get_access_token_info(access_token: str) -> objects.AccessToken: - """ - API to get the info for an access token. - """ - postgres = connectors.PostgresConnector.get_instance() - token = objects.AccessToken.validate_access_token(postgres, access_token) - if not token: - raise osmo_errors.OSMOUserError('Access Token is invalid') - return token - - -@router.post('/api/auth/access_token/user/{token_name}') +@router.post('/api/auth/access_token/{token_name}') def create_access_token(token_name: str, expires_at: str, description: str = '', - user_header: Optional[str] = - fastapi.Header(alias=login.OSMO_USER_HEADER, default=None)): + roles: Optional[List[str]] = fastapi.Query(default=None), + user_name: str = fastapi.Depends(connectors.parse_username)): """ API to create a new access token. + + If roles are specified, all specified roles must be assigned to the user. + If any role is not assigned to the user, the request fails and no token + is created. If no roles are specified, the access token inherits all of the user's + current roles from the user_roles table. """ postgres = connectors.PostgresConnector.get_instance() - user_name = connectors.parse_username(user_header) - access_token = secrets.token_hex(task_lib.REFRESH_TOKEN_LENGTH) - service_config = postgres.get_service_configs() - objects.AccessToken.insert_into_db(postgres, user_name, token_name, access_token, - expires_at, description, - service_config.service_auth.user_roles, - objects.AccessTokenType.USER) + access_token = secrets.token_urlsafe(task_lib.REFRESH_TOKEN_LENGTH) + + if roles is None: + # No roles specified - inherit all user's roles + token_roles = _get_user_role_names(postgres, user_name) + else: + # Use the specified roles - validation happens in insert_into_db + token_roles = roles + + objects.AccessToken.insert_into_db( + postgres, user_name, token_name, access_token, + expires_at, description, token_roles, user_name) return access_token -@router.post('/api/auth/access_token/service/{token_name}') -def create_service_access_token(token_name: str, - expires_at: str, - roles: List[str] = fastapi.Query(default = []), - description: str = ''): +@router.delete('/api/auth/access_token/{token_name}') +def delete_access_token(token_name: str, + user_name: str = fastapi.Depends(connectors.parse_username)): + """ + API to delete an access token. + """ + postgres = connectors.PostgresConnector.get_instance() + objects.AccessToken.delete_from_db(postgres, token_name, user_name) + + +@router.get('/api/auth/access_token/{token_name}/roles', + response_model=objects.AccessTokenRolesResponse) +def list_access_token_roles( + token_name: str, + user_name: str = fastapi.Depends(connectors.parse_username), +) -> objects.AccessTokenRolesResponse: + """ + List all roles assigned to an access token. + + Args: + token_name: The token name + user_name: Authenticated user (owner of the token) + + Returns: + AccessTokenRolesResponse with list of role assignments + """ + postgres = connectors.PostgresConnector.get_instance() + + # Verify token exists and belongs to user + fetch_token_cmd = ''' + SELECT user_name, token_name FROM access_token + WHERE token_name = %s AND user_name = %s; + ''' + token_rows = postgres.execute_fetch_command( + fetch_token_cmd, (token_name, user_name), True) + + if not token_rows: + raise osmo_errors.OSMOUserError( + f'Token {token_name} not found or does not belong to current user') + + # Fetch access token roles by joining with user_roles to get role_name + fetch_cmd = ''' + SELECT ur.role_name, pr.assigned_by, pr.assigned_at + FROM access_token_roles pr + JOIN user_roles ur ON pr.user_role_id = ur.id + WHERE pr.user_name = %s AND pr.token_name = %s + ORDER BY ur.role_name; + ''' + rows = postgres.execute_fetch_command(fetch_cmd, (user_name, token_name), True) + + roles = [objects.AccessTokenRole( + role_name=row['role_name'], + assigned_by=row['assigned_by'], + assigned_at=row['assigned_at'] + ) for row in rows] + + return objects.AccessTokenRolesResponse( + user_name=user_name, + token_name=token_name, + roles=roles + ) + + +@router.get('/api/auth/access_token') +def list_access_tokens( + user_name: str = fastapi.Depends(connectors.parse_username) +) -> List[objects.AccessTokenWithRoles]: """ - API to create a new service access token. + API to list all access tokens for a user, including their assigned roles. + """ + postgres = connectors.PostgresConnector.get_instance() + return objects.AccessToken.list_with_roles_from_db(postgres, user_name) + + +@router.post('/api/auth/user/{user_id}/access_token/{token_name}') +def admin_create_access_token( + user_id: str, + token_name: str, + expires_at: str, + description: str = '', + roles: Optional[List[str]] = fastapi.Query(default=None), + admin_user: str = fastapi.Depends(connectors.parse_username), +): + """ + Admin API to create an access token for a specific user. + + This endpoint allows administrators to create an access token + on behalf of any user in the system. + + If roles are specified, all specified roles must be assigned to the target + user. If any role is not assigned to the user, the request fails and no + token is created. If no roles are specified, the access token inherits all of the + target user's current roles from the user_roles table. + + Args: + user_id: The user ID to create the token for + token_name: Name for the access token + expires_at: Expiration date in YYYY-MM-DD format + description: Optional description for the token + roles: Optional list of roles to assign (must all be assigned to user) + admin_user: Authenticated admin user making the request + + Returns: + The generated access token string """ postgres = connectors.PostgresConnector.get_instance() - # Create serivce account name - service_account_name = token_name - access_token = secrets.token_hex(task_lib.REFRESH_TOKEN_LENGTH) + access_token = secrets.token_urlsafe(task_lib.REFRESH_TOKEN_LENGTH) - config_roles_names = [role.name for role in connectors.Role.list_from_db(postgres)] - for role in roles: - if role not in config_roles_names: - raise osmo_errors.OSMOUserError(f'Invalid role: {role}') + if roles is None: + # No roles specified - inherit all user's roles + token_roles = _get_user_role_names(postgres, user_id) + else: + # Use the specified roles - validation happens in insert_into_db + token_roles = roles - objects.AccessToken.insert_into_db(postgres, service_account_name, token_name, access_token, - expires_at, description, roles, - objects.AccessTokenType.SERVICE) - connectors.UserProfile.insert_default_profile(postgres, service_account_name) + objects.AccessToken.insert_into_db( + postgres, user_id, token_name, access_token, + expires_at, description, token_roles, admin_user) return access_token -@router.delete('/api/auth/access_token/user/{token_name}') -def delete_access_token(token_name: str, - user_header: Optional[str] = - fastapi.Header(alias=login.OSMO_USER_HEADER, default=None)): +@router.get('/api/auth/user/{user_id}/access_tokens') +def admin_list_access_tokens(user_id: str) -> List[objects.AccessTokenWithRoles]: """ - API to delete an access token. + Admin API to list all access tokens for a specific user, including their assigned roles. + + Args: + user_id: The user ID to list tokens for + + Returns: + List of AccessTokenWithRoles objects """ postgres = connectors.PostgresConnector.get_instance() - user_name = connectors.parse_username(user_header) - objects.AccessToken.delete_from_db(postgres, objects.AccessTokenType.USER, token_name, - user_name) + + # Validate the target user exists + _validate_user_exists(postgres, user_id) + + return objects.AccessToken.list_with_roles_from_db(postgres, user_id) -@router.delete('/api/auth/access_token/service/{token_name}') -def delete_service_access_token(token_name: str): +@router.delete('/api/auth/user/{user_id}/access_token/{token_name}') +def admin_delete_access_token(user_id: str, token_name: str): """ - API to delete a service access token. + Admin API to delete an access token for a specific user. + + Args: + user_id: The user ID who owns the token + token_name: Name of the token to delete """ postgres = connectors.PostgresConnector.get_instance() - objects.AccessToken.delete_from_db(postgres, objects.AccessTokenType.SERVICE, token_name) + # Validate the target user exists + _validate_user_exists(postgres, user_id) + + objects.AccessToken.delete_from_db(postgres, token_name, user_id) + + +# ============================================================================= +# User Management Helper Functions +# ============================================================================= + +def _get_user_from_db(postgres: connectors.PostgresConnector, + user_id: str) -> Optional[dict]: + """Fetch a user record from the database.""" + fetch_cmd = ''' + SELECT id, created_at, created_by + FROM users WHERE id = %s; + ''' + rows = postgres.execute_fetch_command(fetch_cmd, (user_id,), True) + return rows[0] if rows else None + + +def _get_user_roles_from_db(postgres: connectors.PostgresConnector, + user_id: str) -> List[objects.UserRole]: + """Fetch all roles assigned to a user.""" + fetch_cmd = ''' + SELECT role_name, assigned_by, assigned_at + FROM user_roles WHERE user_id = %s + ORDER BY role_name; + ''' + rows = postgres.execute_fetch_command(fetch_cmd, (user_id,), True) + return [objects.UserRole( + role_name=row['role_name'], + assigned_by=row['assigned_by'], + assigned_at=row['assigned_at'] + ) for row in rows] + + +def _get_user_role_names(postgres: connectors.PostgresConnector, + user_id: str) -> List[str]: + """Fetch all role names assigned to a user.""" + fetch_cmd = ''' + SELECT role_name FROM user_roles WHERE user_id = %s ORDER BY role_name; + ''' + rows = postgres.execute_fetch_command(fetch_cmd, (user_id,), True) + return [row['role_name'] for row in rows] + + +def _validate_role_exists(postgres: connectors.PostgresConnector, role_name: str): + """Validate that a role exists in the database.""" + fetch_cmd = 'SELECT 1 FROM roles WHERE name = %s;' + rows = postgres.execute_fetch_command(fetch_cmd, (role_name,), True) + if not rows: + raise osmo_errors.OSMOUserError(f'Role {role_name} does not exist') + + +def _validate_user_exists(postgres: connectors.PostgresConnector, user_id: str): + """Validate that a user exists in the database.""" + if not _get_user_from_db(postgres, user_id): + raise osmo_errors.OSMOUserError(f'User {user_id} not found') + + +# ============================================================================= +# User Management APIs +# ============================================================================= -@router.get('/api/auth/access_token/user') -def list_access_tokens(user_header: Optional[str] = - fastapi.Header(alias=login.OSMO_USER_HEADER, default=None)) \ - -> List[objects.AccessToken]: +@router.get('/api/auth/user', response_model=objects.UserListResponse) +def list_users( + start_index: int = 1, + count: int = 100, + id_prefix: Optional[str] = None, + roles: Optional[List[str]] = fastapi.Query(default=None), +) -> objects.UserListResponse: """ - API to list all access tokens for a user. + List all users with optional filtering. + + Args: + start_index: Pagination start (1-based, default: 1) + count: Results per page (default: 100, max: 1000) + id_prefix: Filter users whose ID starts with this prefix + roles: List of role names. Returns users who have ANY of these roles. + Use multiple query params: ?roles=admin&roles=user + + Returns: + UserListResponse with paginated user list """ postgres = connectors.PostgresConnector.get_instance() - user_name = connectors.parse_username(user_header) - return objects.AccessToken.list_from_db(postgres, objects.AccessTokenType.USER, user_name) + # Validate pagination parameters + start_index = max(start_index, 1) + count = max(count, 1) + count = min(count, 1000) + + # Build WHERE clause and args + where_conditions = [] + filter_args: List = [] + + # Add id_prefix filter + if id_prefix: + where_conditions.append('u.id LIKE %s') + filter_args.append(f'{id_prefix}%') + + # Add roles filter (users who have ANY of the specified roles) + role_list = roles if roles else [] + + # Build the query + if role_list: + # Join with user_roles to filter by roles + role_placeholders = ', '.join(['%s'] * len(role_list)) + filter_args.extend(role_list) + + where_clause = '' + if where_conditions: + where_clause = ' AND ' + ' AND '.join(where_conditions) + + # Count query with role filter + count_cmd = f''' + SELECT COUNT(DISTINCT u.id) as total + FROM users u + JOIN user_roles ur ON u.id = ur.user_id + WHERE ur.role_name IN ({role_placeholders}){where_clause}; + ''' + + # Fetch query with role filter + fetch_cmd = f''' + SELECT DISTINCT u.id, u.created_at, u.created_by + FROM users u + JOIN user_roles ur ON u.id = ur.user_id + WHERE ur.role_name IN ({role_placeholders}){where_clause} + ORDER BY u.created_at DESC + LIMIT %s OFFSET %s; + ''' + else: + # No role filter - query users table directly + where_clause = '' + if where_conditions: + where_clause = ' WHERE ' + ' AND '.join(where_conditions) + + count_cmd = f'SELECT COUNT(*) as total FROM users u{where_clause};' + fetch_cmd = f''' + SELECT u.id, u.created_at, u.created_by + FROM users u{where_clause} + ORDER BY u.created_at DESC + LIMIT %s OFFSET %s; + ''' + + # Get total count + count_result = postgres.execute_fetch_command(count_cmd, tuple(filter_args), True) + total_results = count_result[0]['total'] if count_result else 0 + + # Calculate offset (start_index is 1-based) + offset = start_index - 1 + + # Fetch users with pagination + rows = postgres.execute_fetch_command( + fetch_cmd, tuple(filter_args) + (count, offset), True) + + users = [objects.User( + id=row['id'], + created_at=row['created_at'], + created_by=row['created_by'] + ) for row in rows] + + return objects.UserListResponse( + total_results=total_results, + start_index=start_index, + items_per_page=len(users), + users=users + ) + + +@router.post('/api/auth/user', response_model=objects.User) +def create_user( + request: objects.CreateUserRequest, + created_by: str = fastapi.Depends(connectors.parse_username), +) -> objects.User: + """ + Create a new user. + + Args: + request: CreateUserRequest with user details + created_by: Authenticated user making the request + + Returns: + Created User object + """ + postgres = connectors.PostgresConnector.get_instance() + + # Check if user already exists + existing_user = _get_user_from_db(postgres, request.id) + if existing_user: + raise osmo_errors.OSMOUserError(f'User {request.id} already exists') + + # Validate roles if provided + if request.roles: + for role_name in request.roles: + _validate_role_exists(postgres, role_name) -@router.get('/api/auth/access_token/service') -def list_service_access_tokens() -> List[objects.AccessToken]: + now = datetime.datetime.now(datetime.timezone.utc) + + # Insert user + insert_cmd = ''' + INSERT INTO users (id, created_at, created_by) + VALUES (%s, %s, %s) + RETURNING id, created_at, created_by; + ''' + result = postgres.execute_fetch_command( + insert_cmd, + (request.id, now, created_by), + True + ) + + if not result: + raise osmo_errors.OSMODatabaseError('Failed to create user') + + # Assign initial roles if provided + if request.roles: + for role_name in request.roles: + assign_cmd = ''' + INSERT INTO user_roles (user_id, role_name, assigned_by, assigned_at) + VALUES (%s, %s, %s, %s) + ON CONFLICT (user_id, role_name) DO NOTHING; + ''' + postgres.execute_commit_command( + assign_cmd, (request.id, role_name, created_by, now)) + + row = result[0] + return objects.User( + id=row['id'], + created_at=row['created_at'], + created_by=row['created_by'] + ) + + +@router.get('/api/auth/user/{user_id}', response_model=objects.UserWithRoles) +def get_user(user_id: str) -> objects.UserWithRoles: """ - API to list all service access tokens. + Get a specific user's details including their roles. + + Args: + user_id: The user ID to fetch + + Returns: + UserWithRoles object """ postgres = connectors.PostgresConnector.get_instance() - return objects.AccessToken.list_from_db(postgres, objects.AccessTokenType.SERVICE) + + user_row = _get_user_from_db(postgres, user_id) + if not user_row: + raise osmo_errors.OSMOUserError(f'User {user_id} not found') + + roles = _get_user_roles_from_db(postgres, user_id) + + return objects.UserWithRoles( + id=user_row['id'], + created_at=user_row['created_at'], + created_by=user_row['created_by'], + roles=roles + ) + + +@router.delete('/api/auth/user/{user_id}') +def delete_user(user_id: str): + """ + Delete a user and all associated role assignments and PATs. + + Args: + user_id: The user ID to delete + """ + postgres = connectors.PostgresConnector.get_instance() + + # Check if user exists + _validate_user_exists(postgres, user_id) + + # Delete user (cascades to user_roles due to ON DELETE CASCADE) + delete_cmd = 'DELETE FROM users WHERE id = %s;' + postgres.execute_commit_command(delete_cmd, (user_id,)) + + +# ============================================================================= +# User Role Assignment APIs +# ============================================================================= + +@router.get('/api/auth/user/{user_id}/roles', response_model=objects.UserRolesResponse) +def list_user_roles(user_id: str) -> objects.UserRolesResponse: + """ + List all roles assigned to a user. + + Args: + user_id: The user ID + + Returns: + UserRolesResponse with list of role assignments + """ + postgres = connectors.PostgresConnector.get_instance() + + # Validate user exists + _validate_user_exists(postgres, user_id) + + roles = _get_user_roles_from_db(postgres, user_id) + + return objects.UserRolesResponse( + user_id=user_id, + roles=roles + ) + + +@router.post('/api/auth/user/{user_id}/roles', + response_model=objects.UserRoleAssignment) +def assign_role_to_user( + user_id: str, + request: objects.AssignRoleRequest, + assigned_by: str = fastapi.Depends(connectors.parse_username), +) -> objects.UserRoleAssignment: + """ + Assign a role to a user. + + Args: + user_id: The user ID + request: AssignRoleRequest with role_name + assigned_by: Authenticated user making the request + + Returns: + UserRoleAssignment with assignment details + """ + postgres = connectors.PostgresConnector.get_instance() + + # Validate role exists (user existence is enforced by FK constraint on user_roles) + _validate_role_exists(postgres, request.role_name) + + now = datetime.datetime.now(datetime.timezone.utc) + + # Insert role assignment (idempotent - returns existing if already assigned) + # FK constraint on user_id will reject if user doesn't exist + insert_cmd = ''' + INSERT INTO user_roles (user_id, role_name, assigned_by, assigned_at) + VALUES (%s, %s, %s, %s) + ON CONFLICT (user_id, role_name) DO UPDATE SET user_id = EXCLUDED.user_id + RETURNING id, assigned_by, assigned_at; + ''' + try: + result = postgres.execute_fetch_command( + insert_cmd, (user_id, request.role_name, assigned_by, now), True) + except osmo_errors.OSMODatabaseError as err: + raise osmo_errors.OSMOUserError(f'User {user_id} not found') from err + + row = result[0] + return objects.UserRoleAssignment( + user_id=user_id, + role_name=request.role_name, + assigned_by=row['assigned_by'], + assigned_at=row['assigned_at'] + ) + + +@router.delete('/api/auth/user/{user_id}/roles/{role_name}') +def remove_role_from_user(user_id: str, role_name: str): + """ + Remove a role from a user and all their PATs. + + When a role is removed from a user, it is automatically removed from all PATs + owned by that user via the FK cascade from access_token_roles to user_roles. + + Args: + user_id: The user ID + role_name: The role to remove + """ + postgres = connectors.PostgresConnector.get_instance() + + # Delete role assignment from user_roles + # access_token_roles entries referencing this user_role are auto-deleted via ON DELETE CASCADE + delete_cmd = 'DELETE FROM user_roles WHERE user_id = %s AND role_name = %s;' + postgres.execute_commit_command(delete_cmd, (user_id, role_name)) + + +@router.get('/api/auth/roles/{role_name}/users', response_model=objects.RoleUsersResponse) +def list_users_with_role(role_name: str) -> objects.RoleUsersResponse: + """ + List all users who have a specific role. + + Args: + role_name: The role name + + Returns: + RoleUsersResponse with list of users + """ + postgres = connectors.PostgresConnector.get_instance() + + # Validate role exists + _validate_role_exists(postgres, role_name) + + fetch_cmd = ''' + SELECT ur.user_id, ur.assigned_by, ur.assigned_at + FROM user_roles ur + JOIN users u ON ur.user_id = u.id + WHERE ur.role_name = %s + ORDER BY ur.assigned_at DESC; + ''' + rows = postgres.execute_fetch_command(fetch_cmd, (role_name,), True) + + users = [{ + 'user_id': row['user_id'], + 'assigned_by': row['assigned_by'], + 'assigned_at': row['assigned_at'].isoformat() if row['assigned_at'] else None + } for row in rows] + + return objects.RoleUsersResponse( + role_name=role_name, + users=users + ) + + +@router.post('/api/auth/roles/{role_name}/users', + response_model=objects.BulkAssignResponse) +def bulk_assign_role( + role_name: str, + request: objects.BulkAssignRequest, + assigned_by: str = fastapi.Depends(connectors.parse_username), +) -> objects.BulkAssignResponse: + """ + Bulk assign a role to multiple users. + + Args: + role_name: The role to assign + request: BulkAssignRequest with list of user_ids + assigned_by: Authenticated user making the request + + Returns: + BulkAssignResponse with results + """ + postgres = connectors.PostgresConnector.get_instance() + + # Validate role exists + _validate_role_exists(postgres, role_name) + + assigned: List[str] = [] + already_assigned: List[str] = [] + failed: List[str] = [] + + now = datetime.datetime.now(datetime.timezone.utc) + + for user_id in request.user_ids: + # Check if user exists + user = _get_user_from_db(postgres, user_id) + if not user: + failed.append(user_id) + continue + + # Check if already assigned + check_cmd = ''' + SELECT 1 FROM user_roles WHERE user_id = %s AND role_name = %s; + ''' + existing = postgres.execute_fetch_command(check_cmd, (user_id, role_name), True) + + if existing: + already_assigned.append(user_id) + else: + # Assign role + insert_cmd = ''' + INSERT INTO user_roles (user_id, role_name, assigned_by, assigned_at) + VALUES (%s, %s, %s, %s) + ON CONFLICT (user_id, role_name) DO NOTHING; + ''' + postgres.execute_commit_command( + insert_cmd, (user_id, role_name, assigned_by, now)) + assigned.append(user_id) + + return objects.BulkAssignResponse( + role_name=role_name, + assigned=assigned, + already_assigned=already_assigned, + failed=failed + ) diff --git a/src/service/core/auth/objects.py b/src/service/core/auth/objects.py index cb0460b98..4a6756a2e 100644 --- a/src/service/core/auth/objects.py +++ b/src/service/core/auth/objects.py @@ -1,5 +1,5 @@ """ -SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # pylint: disable=line-too-long Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,7 +17,6 @@ """ import datetime -import enum import re from typing import List, Optional @@ -27,93 +26,85 @@ from src.utils import auth, connectors -class AccessTokenType(enum.Enum): - """ Type of access token """ - USER = 'USER' - SERVICE = 'SERVICE' - - class AccessToken(pydantic.BaseModel): - """ Single Pool Entry """ + """Access Token entry.""" user_name: str token_name: str expires_at: datetime.datetime description: str - access_type: AccessTokenType - roles: List[str] @classmethod - def list_from_db(cls, database: connectors.PostgresConnector, access_type: AccessTokenType, - user_name: str | None = None) \ - -> List['AccessToken']: - """ Fetches the list of access tokens from the access token table """ - fetch_cmd = 'SELECT * FROM access_token WHERE access_type = %s' - fetch_params = [access_type.value] - if user_name: - fetch_cmd += ' AND user_name = %s;' - fetch_params.append(user_name) - spec_rows = database.execute_fetch_command(fetch_cmd, tuple(fetch_params), True) - + def list_from_db(cls, database: connectors.PostgresConnector, + user_name: str) -> List['AccessToken']: + """Fetches the list of access tokens from the access token table for a user.""" + fetch_cmd = ''' + SELECT user_name, token_name, expires_at, description + FROM access_token WHERE user_name = %s; + ''' + spec_rows = database.execute_fetch_command(fetch_cmd, (user_name,), True) return [AccessToken(**spec_row) for spec_row in spec_rows] @classmethod - def fetch_from_db(cls, database: connectors.PostgresConnector, access_type: AccessTokenType, - token_name: str, user_name: str | None = None) -> 'AccessToken': - """ Fetches the access token from the access token table """ - fetch_cmd = 'SELECT * FROM access_token WHERE access_type = %s AND token_name = %s' - fetch_params = [access_type.value, token_name] - if user_name: - fetch_cmd += ' AND user_name = %s;' - fetch_params.append(user_name) - spec_rows = database.execute_fetch_command(fetch_cmd, tuple(fetch_params), True) - if not spec_rows: - if access_type == AccessTokenType.USER: - type_str = 'User' - else: - type_str = 'Service' - raise osmo_errors.OSMOUserError(f'{type_str} access token {token_name} does not exist.') - - spec_row = spec_rows[0] + def list_with_roles_from_db(cls, database: connectors.PostgresConnector, + user_name: str) -> List['AccessTokenWithRoles']: + """Fetches access tokens with their roles for a user.""" + fetch_cmd = ''' + SELECT + at.user_name, + at.token_name, + at.expires_at, + at.description, + COALESCE( + ARRAY_AGG(ur.role_name ORDER BY ur.role_name) + FILTER (WHERE ur.role_name IS NOT NULL), + ARRAY[]::text[] + ) as roles + FROM access_token at + LEFT JOIN access_token_roles pr ON at.user_name = pr.user_name AND at.token_name = pr.token_name + LEFT JOIN user_roles ur ON pr.user_role_id = ur.id + WHERE at.user_name = %s + GROUP BY at.user_name, at.token_name, at.expires_at, at.description + ORDER BY at.token_name; + ''' + spec_rows = database.execute_fetch_command(fetch_cmd, (user_name,), True) + return [AccessTokenWithRoles(**spec_row) for spec_row in spec_rows] - return AccessToken(**spec_row) + @classmethod + def fetch_from_db(cls, database: connectors.PostgresConnector, + token_name: str, user_name: str) -> 'AccessToken': + """Fetches the access token from the access token table.""" + fetch_cmd = ''' + SELECT user_name, token_name, expires_at, description + FROM access_token WHERE token_name = %s AND user_name = %s; + ''' + spec_rows = database.execute_fetch_command(fetch_cmd, (token_name, user_name), True) + if not spec_rows: + raise osmo_errors.OSMOUserError(f'Access token {token_name} does not exist.') + return AccessToken(**spec_rows[0]) @classmethod - def delete_from_db(cls, database: connectors.PostgresConnector, access_type: AccessTokenType, - token_name: str, user_name: str | None = None): - """ Delete an entry from the access token table """ - cls.fetch_from_db(database, access_type, token_name, user_name) - if access_type == AccessTokenType.USER: - delete_cmd = ''' - DELETE FROM access_token - WHERE access_type = %s - AND token_name = %s - AND user_name = %s; - ''' - delete_params = [access_type.value, token_name, user_name] - else: - delete_cmd = ''' - BEGIN; - DELETE FROM profile where user_name = ( - SELECT user_name FROM access_token - WHERE access_type = %s AND token_name = %s); - DELETE FROM ueks where uid = ( - SELECT user_name FROM access_token - WHERE access_type = %s AND token_name = %s); - DELETE FROM credential where user_name = ( - SELECT user_name FROM access_token - WHERE access_type = %s AND token_name = %s); - DELETE FROM access_token WHERE access_type = %s AND token_name = %s; - COMMIT; - ''' - delete_params = [access_type.value, token_name, access_type.value, token_name, - access_type.value, token_name, access_type.value, token_name] - database.execute_commit_command(delete_cmd, tuple(delete_params)) + def delete_from_db(cls, database: connectors.PostgresConnector, + token_name: str, user_name: str): + """Delete an entry from the access token table.""" + cls.fetch_from_db(database, token_name, user_name) + # access_token_roles will be deleted via ON DELETE CASCADE + delete_cmd = ''' + DELETE FROM access_token + WHERE token_name = %s AND user_name = %s; + ''' + database.execute_commit_command(delete_cmd, (token_name, user_name)) @classmethod - def insert_into_db(cls, database: connectors.PostgresConnector, user_name: str, token_name: str, - access_token: str, expires_at: str, description: str, roles: List[str], - access_type: AccessTokenType): - """ Create/update an entry in the access token table """ + def insert_into_db(cls, database: connectors.PostgresConnector, user_name: str, + token_name: str, access_token: str, expires_at: str, + description: str, roles: List[str], assigned_by: str): + """Create an entry in the access token table and assign roles via access_token_roles. + + This operation is atomic - the role validation and all inserts happen in a + single SQL transaction. If any requested role is not assigned to the user + in the user_roles table at the moment of insert, the entire operation fails + and no token is created. + """ if not re.fullmatch(common.TOKEN_NAME_REGEX, token_name): raise osmo_errors.OSMOUserError( f'Token name {token_name} must match regex {common.TOKEN_NAME_REGEX}') @@ -135,26 +126,190 @@ def insert_into_db(cls, database: connectors.PostgresConnector, user_name: str, raise osmo_errors.OSMOUserError( f'Access token cannot last longer than {max_token_duration}') + if not roles: + raise osmo_errors.OSMOUserError( + 'At least one role must be specified for the access token.') + + now = datetime.datetime.now(datetime.timezone.utc) + hashed_token = auth.hash_access_token(access_token) + + # Atomic insert with role validation using CTEs + # The query validates roles and inserts in a single transaction. + # access_token roles reference user_roles.id via FK, so: + # - Token is only created if ALL requested roles exist in user_roles + # - When a user role is later deleted, access_token roles cascade delete automatically + # + # The role_check CTE verifies all roles exist before any insert happens. + # If the count doesn't match, the WHERE clause prevents token creation. insert_cmd = ''' - INSERT INTO access_token - (user_name, token_name, access_token, expires_at, description, access_type, roles) - VALUES (%s, %s, %s, %s, %s, %s, %s); - ''' + WITH matching_user_roles AS ( + SELECT ur.id as user_role_id, ur.role_name + FROM user_roles ur + WHERE ur.user_id = %s AND ur.role_name = ANY(%s::text[]) + ), + role_check AS ( + SELECT COUNT(*) = %s AS all_roles_found FROM matching_user_roles + ), + token_insert AS ( + INSERT INTO access_token + (user_name, token_name, access_token, expires_at, description) + SELECT %s, %s, %s, %s, %s + WHERE (SELECT all_roles_found FROM role_check) + RETURNING user_name, token_name + ), + role_insert AS ( + INSERT INTO access_token_roles (user_name, token_name, user_role_id, assigned_by, assigned_at) + SELECT ti.user_name, ti.token_name, mur.user_role_id, %s, %s + FROM token_insert ti + CROSS JOIN matching_user_roles mur + RETURNING user_role_id + ) + SELECT + (SELECT all_roles_found FROM role_check) as all_roles_found, + (SELECT COUNT(*) FROM token_insert) as token_created; + ''' + args = ( + user_name, roles, len(roles), + user_name, token_name, hashed_token, expires_at, description, + assigned_by, now + ) + try: - database.execute_commit_command( - insert_cmd, - (user_name, token_name, auth.hash_access_token(access_token), expires_at, - description, access_type.value, roles)) + result = database.execute_fetch_command(insert_cmd, args, True) + if result: + all_roles_found = result[0].get('all_roles_found', False) + token_created = result[0].get('token_created', 0) + if not all_roles_found or token_created == 0: + raise osmo_errors.OSMOUserError( + 'User does not have all the requested roles. ' + 'Token creation failed.') except osmo_errors.OSMODatabaseError as e: - raise osmo_errors.OSMOUserError(f'Token name {token_name} already exists.') from e + error_str = str(e).lower() + if 'already exists' in error_str or 'duplicate key' in error_str: + raise osmo_errors.OSMOUserError( + f'Token name {token_name} already exists.') from e + raise @classmethod def validate_access_token(cls, database: connectors.PostgresConnector, access_token: str) \ -> Optional['AccessToken']: - """ Validate the access token """ - fetch_cmd = 'SELECT * FROM access_token WHERE access_token = %s;' + """Validate the access token.""" + fetch_cmd = ''' + SELECT user_name, token_name, expires_at, description + FROM access_token WHERE access_token = %s; + ''' spec_rows = database.execute_fetch_command( fetch_cmd, (auth.hash_access_token(access_token),), True) if not spec_rows: return None return AccessToken(**spec_rows[0]) + + @classmethod + def get_roles_for_token(cls, database: connectors.PostgresConnector, + user_name: str, token_name: str) -> List[str]: + """ + Get the roles assigned to a access_token by joining access_token_roles with user_roles. + """ + fetch_cmd = ''' + SELECT ur.role_name + FROM access_token_roles pr + JOIN user_roles ur ON pr.user_role_id = ur.id + WHERE pr.user_name = %s AND pr.token_name = %s + ORDER BY ur.role_name; + ''' + rows = database.execute_fetch_command(fetch_cmd, (user_name, token_name), True) + return [row['role_name'] for row in rows] + + +class AccessTokenWithRoles(AccessToken): + """Access Token with roles.""" + roles: List[str] = [] + + +# ============================================================================= +# User Management Objects +# ============================================================================= + +class UserRole(pydantic.BaseModel): + """User role assignment.""" + role_name: str + assigned_by: str + assigned_at: datetime.datetime + + +class User(pydantic.BaseModel): + """User record.""" + id: str + created_at: Optional[datetime.datetime] = None + created_by: Optional[str] = None + + +class UserWithRoles(User): + """User record with role assignments.""" + roles: List[UserRole] = [] + + +class CreateUserRequest(pydantic.BaseModel): + """Request to create a new user.""" + id: str + roles: Optional[List[str]] = None + + +class AssignRoleRequest(pydantic.BaseModel): + """Request to assign a role to a user.""" + role_name: str + + +class UserRoleAssignment(pydantic.BaseModel): + """User role assignment response.""" + user_id: str + role_name: str + assigned_by: str + assigned_at: datetime.datetime + + +class UserListResponse(pydantic.BaseModel): + """Response for listing users.""" + total_results: int + start_index: int + items_per_page: int + users: List[User] + + +class UserRolesResponse(pydantic.BaseModel): + """Response for listing user roles.""" + user_id: str + roles: List[UserRole] + + +class RoleUsersResponse(pydantic.BaseModel): + """Response for listing users with a role.""" + role_name: str + users: List[dict] + + +class BulkAssignRequest(pydantic.BaseModel): + """Request to bulk assign a role to users.""" + user_ids: List[str] + + +class BulkAssignResponse(pydantic.BaseModel): + """Response for bulk role assignment.""" + role_name: str + assigned: List[str] + already_assigned: List[str] + failed: List[str] + + +class AccessTokenRole(pydantic.BaseModel): + """Access token role assignment.""" + role_name: str + assigned_by: str + assigned_at: datetime.datetime + + +class AccessTokenRolesResponse(pydantic.BaseModel): + """Response for listing access token roles.""" + user_name: str + token_name: str + roles: List[AccessTokenRole] diff --git a/src/service/core/auth/tests/BUILD b/src/service/core/auth/tests/BUILD new file mode 100644 index 000000000..5f17e1363 --- /dev/null +++ b/src/service/core/auth/tests/BUILD @@ -0,0 +1,33 @@ +""" +SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" + +load("//bzl:py.bzl", "osmo_py_test") + +osmo_py_test( + name = "test_auth_service", + srcs = ["test_auth_service.py"], + deps = [ + "//src/service/core/auth:auth", + "//src/service/core/tests:fixture", + "//src/tests/common", + "//src/utils/connectors", + ], + size = "large", + tags = ["requires-network"], + visibility = ["//visibility:public"], +) diff --git a/src/service/core/auth/tests/__init__.py b/src/service/core/auth/tests/__init__.py new file mode 100644 index 000000000..6603dc9d7 --- /dev/null +++ b/src/service/core/auth/tests/__init__.py @@ -0,0 +1,17 @@ +""" +SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" diff --git a/src/service/core/auth/tests/test_auth_service.py b/src/service/core/auth/tests/test_auth_service.py new file mode 100644 index 000000000..49a100796 --- /dev/null +++ b/src/service/core/auth/tests/test_auth_service.py @@ -0,0 +1,745 @@ +""" +SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" + +from typing import Any, Dict, List, Optional + +from src.service.core.auth import objects +from src.service.core.tests import fixture +from src.utils import connectors +from src.tests.common import runner + + +class AuthServiceTestCase(fixture.ServiceTestFixture): + """Integration tests for auth service user and role management.""" + + TEST_USER = 'test@nvidia.com' + TEST_ADMIN = 'admin@nvidia.com' + + def setUp(self): + super().setUp() + # Set default auth header to TEST_USER + self.client.headers['x-osmo-user'] = self.TEST_USER + # Clean up test users from previous tests to ensure isolation + self._cleanup_test_users() + # Create test roles for use in tests + self._create_test_role('osmo-user', 'Default user role') + self._create_test_role('osmo-admin', 'Admin role') + self._create_test_role('osmo-ml-team', 'ML team role') + self._create_test_role('osmo-dev-team', 'Dev team role') + + def _cleanup_test_users(self): + """Clean up test users to ensure test isolation.""" + postgres = connectors.PostgresConnector.get_instance() + # Delete users (CASCADE will handle user_roles, access_token_roles, access_token) + postgres.execute_commit_command( + 'DELETE FROM users WHERE id = %s OR id = %s;', + (self.TEST_USER, self.TEST_ADMIN) + ) + postgres.execute_commit_command( + 'DELETE FROM users WHERE id LIKE %s;', ('%@example.com',) + ) + + def _create_test_role(self, role_name: str, description: str = ''): + """Helper to create a role in the database.""" + postgres = connectors.PostgresConnector.get_instance() + insert_cmd = ''' + INSERT INTO roles (name, description, policies, immutable, sync_mode) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (name) DO NOTHING; + ''' + postgres.execute_commit_command( + insert_cmd, (role_name, description, [], False, 'import')) + + def _create_user(self, user_id: str, roles: Optional[List[str]] = None) -> Dict: + """Helper to create a user via API. + + First deletes any existing user with the same ID to ensure a clean state. + For TEST_USER, uses direct DB access since the auth service auto-creates + users from the x-osmo-user header. + """ + # Delete user first if it exists to ensure clean test state + postgres = connectors.PostgresConnector.get_instance() + postgres.execute_commit_command( + 'DELETE FROM users WHERE id = %s;', (user_id,)) + + # For TEST_USER (used in x-osmo-user header), the API auto-creates the user, + # so we need to use direct DB access to set up the test state + if user_id == self.TEST_USER: + # Create user directly in DB + postgres.execute_commit_command( + 'INSERT INTO users (id, created_by) VALUES (%s, %s) ON CONFLICT (id) DO NOTHING;', + (user_id, 'test')) + # Add roles if specified + if roles: + for role_name in roles: + postgres.execute_commit_command(''' + INSERT INTO user_roles (user_id, role_name, assigned_by) + VALUES (%s, %s, %s) + ON CONFLICT (user_id, role_name) DO NOTHING; + ''', (user_id, role_name, 'test')) + # Return user data in the expected format + return {'id': user_id, 'created_at': None, 'roles': roles or []} + + payload: Dict[str, Any] = {'id': user_id} + if roles: + payload['roles'] = roles + + response = self.client.post('/api/auth/user', json=payload) + self.assertEqual(response.status_code, 200) + return response.json() + + def _get_user(self, user_id: str) -> Dict: + """Helper to get a user via API.""" + response = self.client.get(f'/api/auth/user/{user_id}') + self.assertEqual(response.status_code, 200) + return response.json() + + def _assign_role(self, user_id: str, role_name: str) -> Dict: + """Helper to assign a role to a user.""" + response = self.client.post( + f'/api/auth/user/{user_id}/roles', + json={'role_name': role_name} + ) + self.assertEqual(response.status_code, 200) + return response.json() + + def _create_access_token(self, token_name: str, expires_at: str = '2027-01-01', + description: str = '', roles: Optional[List[str]] = None) -> str: + """Helper to create an access token for the authenticated user.""" + params: Dict[str, Any] = {'expires_at': expires_at} + if description: + params['description'] = description + if roles: + params['roles'] = roles + + response = self.client.post( + f'/api/auth/access_token/{token_name}', + params=params + ) + self.assertEqual(response.status_code, 200) + return response.json() + + def _get_access_token_roles(self, user_name: str, token_name: str) -> List[str]: + """Helper to get access token roles directly from the database.""" + postgres = connectors.PostgresConnector.get_instance() + fetch_cmd = ''' + SELECT ur.role_name FROM access_token_roles pr + JOIN user_roles ur ON pr.user_role_id = ur.id + WHERE pr.user_name = %s AND pr.token_name = %s + ORDER BY ur.role_name; + ''' + rows = postgres.execute_fetch_command(fetch_cmd, (user_name, token_name), True) + return [row['role_name'] for row in rows] + + # ========================================================================= + # User Management Tests + # ========================================================================= + + def test_create_user(self): + """Test creating a new user without roles.""" + user = self._create_user('newuser@example.com') + + self.assertEqual(user['id'], 'newuser@example.com') + self.assertIsNotNone(user['created_at']) + + def test_create_user_with_roles(self): + """Test creating a user with initial roles.""" + user = self._create_user('roleuser@example.com', roles=['osmo-user', 'osmo-ml-team']) + + self.assertEqual(user['id'], 'roleuser@example.com') + + # Verify roles were assigned + user_details = self._get_user('roleuser@example.com') + role_names = [r['role_name'] for r in user_details['roles']] + self.assertIn('osmo-user', role_names) + self.assertIn('osmo-ml-team', role_names) + + def test_create_user_duplicate_fails(self): + """Test that creating a duplicate user fails.""" + self._create_user('duplicate@example.com') + + response = self.client.post( + '/api/auth/user', + json={'id': 'duplicate@example.com'} + ) + self.assertEqual(response.status_code, 400) + self.assertIn('already exists', response.json()['message']) + + def test_create_user_with_invalid_role_fails(self): + """Test that creating a user with a non-existent role fails.""" + response = self.client.post( + '/api/auth/user', + json={'id': 'baduser@example.com', 'roles': ['nonexistent-role']} + ) + self.assertEqual(response.status_code, 400) + self.assertIn('does not exist', response.json()['message']) + + def test_get_user(self): + """Test getting user details.""" + self._create_user('getuser@example.com', roles=['osmo-user']) + + user = self._get_user('getuser@example.com') + + self.assertEqual(user['id'], 'getuser@example.com') + self.assertIn('roles', user) + self.assertEqual(len(user['roles']), 1) + self.assertEqual(user['roles'][0]['role_name'], 'osmo-user') + + def test_get_user_not_found(self): + """Test getting a non-existent user returns 400.""" + response = self.client.get('/api/auth/user/nonexistent@example.com') + self.assertEqual(response.status_code, 400) + self.assertIn('not found', response.json()['message']) + + def test_list_users(self): + """Test listing users.""" + self._create_user('list1@example.com') + self._create_user('list2@example.com') + self._create_user('list3@example.com') + + response = self.client.get('/api/auth/user') + self.assertEqual(response.status_code, 200) + + result = response.json() + self.assertIn('users', result) + self.assertIn('total_results', result) + self.assertGreaterEqual(result['total_results'], 3) + + user_ids = [u['id'] for u in result['users']] + self.assertIn('list1@example.com', user_ids) + self.assertIn('list2@example.com', user_ids) + self.assertIn('list3@example.com', user_ids) + + def test_list_users_with_id_prefix(self): + """Test listing users with id_prefix filter.""" + self._create_user('prefix-user1@example.com') + self._create_user('prefix-user2@example.com') + self._create_user('other@example.com') + + response = self.client.get('/api/auth/user', params={'id_prefix': 'prefix-'}) + self.assertEqual(response.status_code, 200) + + result = response.json() + user_ids = [u['id'] for u in result['users']] + self.assertIn('prefix-user1@example.com', user_ids) + self.assertIn('prefix-user2@example.com', user_ids) + self.assertNotIn('other@example.com', user_ids) + + def test_list_users_with_roles_filter(self): + """Test listing users filtered by roles.""" + self._create_user('admin1@example.com', roles=['osmo-admin']) + self._create_user('admin2@example.com', roles=['osmo-admin', 'osmo-user']) + self._create_user('regular@example.com', roles=['osmo-user']) + + response = self.client.get('/api/auth/user', params={'roles': ['osmo-admin']}) + self.assertEqual(response.status_code, 200) + + result = response.json() + user_ids = [u['id'] for u in result['users']] + self.assertIn('admin1@example.com', user_ids) + self.assertIn('admin2@example.com', user_ids) + self.assertNotIn('regular@example.com', user_ids) + + def test_list_users_pagination(self): + """Test listing users with pagination.""" + for i in range(5): + self._create_user(f'page-user{i}@example.com') + + response = self.client.get('/api/auth/user', params={'count': 2, 'start_index': 1}) + self.assertEqual(response.status_code, 200) + + result = response.json() + self.assertEqual(result['items_per_page'], 2) + self.assertEqual(result['start_index'], 1) + self.assertGreaterEqual(result['total_results'], 5) + + def test_delete_user(self): + """Test deleting a user.""" + self._create_user('deleteuser@example.com', roles=['osmo-user']) + + response = self.client.delete('/api/auth/user/deleteuser@example.com') + self.assertEqual(response.status_code, 200) + + # Verify user is gone + response = self.client.get('/api/auth/user/deleteuser@example.com') + self.assertEqual(response.status_code, 400) + + def test_delete_user_cascades_to_roles(self): + """Test that deleting a user removes their role assignments.""" + self._create_user('cascade-user@example.com', roles=['osmo-user', 'osmo-admin']) + + # Verify roles exist + postgres = connectors.PostgresConnector.get_instance() + fetch_cmd = 'SELECT COUNT(*) as cnt FROM user_roles WHERE user_id = %s;' + result = postgres.execute_fetch_command(fetch_cmd, ('cascade-user@example.com',), True) + self.assertEqual(result[0]['cnt'], 2) + + # Delete user + response = self.client.delete('/api/auth/user/cascade-user@example.com') + self.assertEqual(response.status_code, 200) + + # Verify roles are gone (cascaded) + result = postgres.execute_fetch_command(fetch_cmd, ('cascade-user@example.com',), True) + self.assertEqual(result[0]['cnt'], 0) + + def test_delete_user_not_found(self): + """Test deleting a non-existent user returns 400.""" + response = self.client.delete('/api/auth/user/nonexistent@example.com') + self.assertEqual(response.status_code, 400) + + # ========================================================================= + # Role Assignment Tests + # ========================================================================= + + def test_assign_role_to_user(self): + """Test assigning a role to a user.""" + self._create_user('roleassign@example.com') + + result = self._assign_role('roleassign@example.com', 'osmo-user') + + self.assertEqual(result['user_id'], 'roleassign@example.com') + self.assertEqual(result['role_name'], 'osmo-user') + self.assertIn('assigned_by', result) + self.assertIn('assigned_at', result) + + def test_assign_role_idempotent(self): + """Test that assigning the same role twice is idempotent.""" + self._create_user('idempotent@example.com') + self._assign_role('idempotent@example.com', 'osmo-user') + + # Assign again - should not fail + response = self.client.post( + '/api/auth/user/idempotent@example.com/roles', + json={'role_name': 'osmo-user'} + ) + self.assertEqual(response.status_code, 200) + + # Verify only one assignment exists + user = self._get_user('idempotent@example.com') + role_names = [r['role_name'] for r in user['roles']] + self.assertEqual(role_names.count('osmo-user'), 1) + + def test_assign_nonexistent_role_fails(self): + """Test that assigning a non-existent role fails.""" + self._create_user('badrole@example.com') + + response = self.client.post( + '/api/auth/user/badrole@example.com/roles', + json={'role_name': 'fake-role'} + ) + self.assertEqual(response.status_code, 400) + self.assertIn('does not exist', response.json()['message']) + + def test_assign_role_to_nonexistent_user_fails(self): + """Test that assigning a role to a non-existent user fails.""" + response = self.client.post( + '/api/auth/user/nobody@example.com/roles', + json={'role_name': 'osmo-user'} + ) + self.assertEqual(response.status_code, 400) + self.assertIn('not found', response.json()['message']) + + def test_remove_role_from_user(self): + """Test removing a role from a user.""" + self._create_user('removerole@example.com', roles=['osmo-user', 'osmo-admin']) + + response = self.client.delete('/api/auth/user/removerole@example.com/roles/osmo-admin') + self.assertEqual(response.status_code, 200) + + # Verify role is removed + user = self._get_user('removerole@example.com') + role_names = [r['role_name'] for r in user['roles']] + self.assertNotIn('osmo-admin', role_names) + self.assertIn('osmo-user', role_names) + + def test_remove_role_cascades_to_access_tokens(self): + """Test that removing a role from a user also removes it from their access tokens.""" + # Create user with roles + self._create_user(self.TEST_USER, roles=['osmo-user', 'osmo-admin', 'osmo-ml-team']) + + # Create an access token that inherits all roles + self._create_access_token('test-token') + + # Verify access token has all roles + token_roles = self._get_access_token_roles(self.TEST_USER, 'test-token') + self.assertIn('osmo-user', token_roles) + self.assertIn('osmo-admin', token_roles) + self.assertIn('osmo-ml-team', token_roles) + + # Remove a role from the user + response = self.client.delete(f'/api/auth/user/{self.TEST_USER}/roles/osmo-admin') + self.assertEqual(response.status_code, 200) + + # Verify role is removed from both user and access token + user = self._get_user(self.TEST_USER) + user_role_names = [r['role_name'] for r in user['roles']] + self.assertNotIn('osmo-admin', user_role_names) + + token_roles = self._get_access_token_roles(self.TEST_USER, 'test-token') + self.assertNotIn('osmo-admin', token_roles) + self.assertIn('osmo-user', token_roles) + self.assertIn('osmo-ml-team', token_roles) + + def test_remove_role_cascades_to_multiple_access_tokens(self): + """Test that removing a role cascades to all of user's access tokens.""" + self._create_user(self.TEST_USER, roles=['osmo-user', 'osmo-admin']) + + # Create multiple access tokens + self._create_access_token('token1') + self._create_access_token('token2') + self._create_access_token('token3') + + # Verify all access tokens have the role + for token_name in ['token1', 'token2', 'token3']: + token_roles = self._get_access_token_roles(self.TEST_USER, token_name) + self.assertIn('osmo-admin', token_roles) + + # Remove role from user + response = self.client.delete(f'/api/auth/user/{self.TEST_USER}/roles/osmo-admin') + self.assertEqual(response.status_code, 200) + + # Verify role is removed from all access tokens + for token_name in ['token1', 'token2', 'token3']: + token_roles = self._get_access_token_roles(self.TEST_USER, token_name) + self.assertNotIn('osmo-admin', token_roles) + self.assertIn('osmo-user', token_roles) + + def test_list_user_roles(self): + """Test listing roles for a user.""" + self._create_user('listroles@example.com', roles=['osmo-user', 'osmo-admin']) + + response = self.client.get('/api/auth/user/listroles@example.com/roles') + self.assertEqual(response.status_code, 200) + + result = response.json() + self.assertEqual(result['user_id'], 'listroles@example.com') + role_names = [r['role_name'] for r in result['roles']] + self.assertIn('osmo-user', role_names) + self.assertIn('osmo-admin', role_names) + + def test_list_users_with_role(self): + """Test listing all users who have a specific role.""" + self._create_user('rolelist1@example.com', roles=['osmo-ml-team']) + self._create_user('rolelist2@example.com', roles=['osmo-ml-team']) + self._create_user('rolelist3@example.com', roles=['osmo-dev-team']) + + response = self.client.get('/api/auth/roles/osmo-ml-team/users') + self.assertEqual(response.status_code, 200) + + result = response.json() + self.assertEqual(result['role_name'], 'osmo-ml-team') + user_ids = [u['user_id'] for u in result['users']] + self.assertIn('rolelist1@example.com', user_ids) + self.assertIn('rolelist2@example.com', user_ids) + self.assertNotIn('rolelist3@example.com', user_ids) + + def test_bulk_assign_role(self): + """Test bulk assigning a role to multiple users.""" + self._create_user('bulk1@example.com') + self._create_user('bulk2@example.com') + self._create_user('bulk3@example.com', roles=['osmo-dev-team']) # Already has it + + response = self.client.post( + '/api/auth/roles/osmo-dev-team/users', + json={'user_ids': ['bulk1@example.com', 'bulk2@example.com', 'bulk3@example.com', + 'nonexistent@example.com']} + ) + self.assertEqual(response.status_code, 200) + + result = response.json() + self.assertEqual(result['role_name'], 'osmo-dev-team') + self.assertIn('bulk1@example.com', result['assigned']) + self.assertIn('bulk2@example.com', result['assigned']) + self.assertIn('bulk3@example.com', result['already_assigned']) + self.assertIn('nonexistent@example.com', result['failed']) + + # ========================================================================= + # Access Token Tests + # ========================================================================= + + def test_create_access_token_inherits_all_roles(self): + """Test that creating an access token without specifying roles inherits all user roles.""" + self._create_user(self.TEST_USER, roles=['osmo-user', 'osmo-admin', 'osmo-ml-team']) + + token = self._create_access_token('inherit-all-token') + self.assertIsNotNone(token) + + # Verify access token has all user roles + token_roles = self._get_access_token_roles(self.TEST_USER, 'inherit-all-token') + self.assertEqual(sorted(token_roles), ['osmo-admin', 'osmo-ml-team', 'osmo-user']) + + def test_create_access_token_with_subset_of_roles(self): + """Test creating an access token with a specific subset of user's roles.""" + self._create_user(self.TEST_USER, roles=['osmo-user', 'osmo-admin', 'osmo-ml-team']) + + params = { + 'expires_at': '2027-01-01', + 'roles': ['osmo-user', 'osmo-ml-team'] # Subset of user's roles + } + response = self.client.post('/api/auth/access_token/subset-token', params=params) + self.assertEqual(response.status_code, 200) + + # Verify access token has only the specified roles + token_roles = self._get_access_token_roles(self.TEST_USER, 'subset-token') + self.assertEqual(sorted(token_roles), ['osmo-ml-team', 'osmo-user']) + self.assertNotIn('osmo-admin', token_roles) + + def test_create_access_token_with_unassigned_role_fails(self): + """Test that creating an access token with roles not assigned to user fails.""" + self._create_user(self.TEST_USER, roles=['osmo-user']) + + params = { + 'expires_at': '2027-01-01', + 'roles': ['osmo-user', 'osmo-admin'] # osmo-admin not assigned to user + } + response = self.client.post('/api/auth/access_token/bad-token', params=params) + self.assertEqual(response.status_code, 400) + self.assertIn('does not have all the requested roles', response.json()['message']) + + def test_create_access_token_user_with_no_roles_fails(self): + """Test that creating an access token for a user with no roles fails.""" + self._create_user(self.TEST_USER) # No roles + + response = self.client.post( + '/api/auth/access_token/no-roles-token', + params={'expires_at': '2027-01-01'} + ) + self.assertEqual(response.status_code, 400) + self.assertIn('At least one role', response.json()['message']) + + def test_create_access_token_duplicate_name_fails(self): + """Test that creating an access token with duplicate name fails.""" + self._create_user(self.TEST_USER, roles=['osmo-user']) + self._create_access_token('duplicate-token') + + response = self.client.post( + '/api/auth/access_token/duplicate-token', + params={'expires_at': '2027-01-01'} + ) + self.assertEqual(response.status_code, 400) + self.assertIn('already exists', response.json()['message']) + + def test_list_access_tokens(self): + """Test listing access tokens for a user.""" + self._create_user(self.TEST_USER, roles=['osmo-user']) + self._create_access_token('token1', description='First token') + self._create_access_token('token2', description='Second token') + + response = self.client.get('/api/auth/access_token') + self.assertEqual(response.status_code, 200) + + tokens = response.json() + token_names = [t['token_name'] for t in tokens] + self.assertIn('token1', token_names) + self.assertIn('token2', token_names) + + def test_delete_access_token(self): + """Test deleting an access token.""" + self._create_user(self.TEST_USER, roles=['osmo-user']) + self._create_access_token('delete-me-token') + + response = self.client.delete('/api/auth/access_token/delete-me-token') + self.assertEqual(response.status_code, 200) + + # Verify token is gone + response = self.client.get('/api/auth/access_token') + token_names = [t['token_name'] for t in response.json()] + self.assertNotIn('delete-me-token', token_names) + + def test_delete_access_token_cascades_access_token_roles(self): + """Test that deleting an access token removes its access_token_roles entries.""" + self._create_user(self.TEST_USER, roles=['osmo-user', 'osmo-admin']) + self._create_access_token('cascade-delete-token') + + # Verify access_token_roles exist + postgres = connectors.PostgresConnector.get_instance() + fetch_cmd = ''' + SELECT COUNT(*) as cnt FROM access_token_roles + WHERE user_name = %s AND token_name = %s; + ''' + result = postgres.execute_fetch_command( + fetch_cmd, (self.TEST_USER, 'cascade-delete-token'), True) + self.assertEqual(result[0]['cnt'], 2) + + # Delete token + response = self.client.delete('/api/auth/access_token/cascade-delete-token') + self.assertEqual(response.status_code, 200) + + # Verify access_token_roles are gone + result = postgres.execute_fetch_command( + fetch_cmd, (self.TEST_USER, 'cascade-delete-token'), True) + self.assertEqual(result[0]['cnt'], 0) + + def test_list_access_token_roles(self): + """Test listing roles for a specific access token.""" + self._create_user(self.TEST_USER, roles=['osmo-user', 'osmo-admin']) + self._create_access_token('roles-token') + + response = self.client.get('/api/auth/access_token/roles-token/roles') + self.assertEqual(response.status_code, 200) + + result = response.json() + self.assertEqual(result['token_name'], 'roles-token') + role_names = [r['role_name'] for r in result['roles']] + self.assertIn('osmo-user', role_names) + self.assertIn('osmo-admin', role_names) + + # ========================================================================= + # Admin API Tests + # ========================================================================= + + def test_admin_create_access_token_for_user(self): + """Test admin creating an access token for another user.""" + self._create_user('target-user@example.com', roles=['osmo-user', 'osmo-ml-team']) + + response = self.client.post( + '/api/auth/user/target-user@example.com/access_token/admin-created-token', + params={'expires_at': '2027-01-01', 'description': 'Admin created token'} + ) + self.assertEqual(response.status_code, 200) + + # Verify token was created with correct roles + token_roles = self._get_access_token_roles('target-user@example.com', 'admin-created-token') + self.assertEqual(sorted(token_roles), ['osmo-ml-team', 'osmo-user']) + + def test_admin_create_access_token_for_nonexistent_user_fails(self): + """Test that admin creating access token for non-existent user fails.""" + response = self.client.post( + '/api/auth/user/nobody@example.com/access_token/admin-token', + params={'expires_at': '2027-01-01'} + ) + self.assertEqual(response.status_code, 400) + # Service returns role error because user has no roles (doesn't exist) + # Either 'not found' or 'role' error message is acceptable + message = response.json()['message'].lower() + self.assertTrue( + 'not found' in message or 'role' in message, + f"Expected 'not found' or 'role' in message, got: {message}" + ) + + def test_admin_list_access_tokens_for_user(self): + """Test admin listing access tokens for another user.""" + self._create_user('list-target@example.com', roles=['osmo-user']) + + # Create tokens for the target user via API + response = self.client.post( + '/api/auth/user/list-target@example.com/access_token/user-token-1', + params={'expires_at': '2027-01-01', 'description': 'Token 1'} + ) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + '/api/auth/user/list-target@example.com/access_token/user-token-2', + params={'expires_at': '2027-01-01', 'description': 'Token 2'} + ) + self.assertEqual(response.status_code, 200) + + response = self.client.get('/api/auth/user/list-target@example.com/access_tokens') + self.assertEqual(response.status_code, 200) + + tokens = response.json() + token_names = [t['token_name'] for t in tokens] + self.assertIn('user-token-1', token_names) + self.assertIn('user-token-2', token_names) + + def test_admin_delete_access_token_for_user(self): + """Test admin deleting another user's access token.""" + self._create_user('delete-target@example.com', roles=['osmo-user']) + + # Create token for target user via API + response = self.client.post( + '/api/auth/user/delete-target@example.com/access_token/target-token', + params={'expires_at': '2027-01-01', 'description': 'To be deleted'} + ) + self.assertEqual(response.status_code, 200) + + response = self.client.delete( + '/api/auth/user/delete-target@example.com/access_token/target-token' + ) + self.assertEqual(response.status_code, 200) + + # Verify token is gone + postgres = connectors.PostgresConnector.get_instance() + tokens = objects.AccessToken.list_from_db(postgres, 'delete-target@example.com') + token_names = [t.token_name for t in tokens] + self.assertNotIn('target-token', token_names) + + # ========================================================================= + # Edge Case Tests + # ========================================================================= + + def test_remove_nonexistent_role_from_user_succeeds(self): + """Test that removing a role the user doesn't have succeeds silently.""" + self._create_user('norole@example.com', roles=['osmo-user']) + + # osmo-admin was never assigned + response = self.client.delete('/api/auth/user/norole@example.com/roles/osmo-admin') + self.assertEqual(response.status_code, 200) + + def test_user_deletion_cascades_to_access_tokens(self): + """Test that deleting a user cascades to their access tokens.""" + self._create_user('token-cascade@example.com', roles=['osmo-user']) + + # Create token for target user via API + response = self.client.post( + '/api/auth/user/token-cascade@example.com/access_token/cascade-token', + params={'expires_at': '2027-01-01', 'description': 'Cascade test'} + ) + self.assertEqual(response.status_code, 200) + + # Verify access token exists + postgres = connectors.PostgresConnector.get_instance() + tokens = objects.AccessToken.list_from_db(postgres, 'token-cascade@example.com') + self.assertEqual(len(tokens), 1) + + # Delete user + response = self.client.delete('/api/auth/user/token-cascade@example.com') + self.assertEqual(response.status_code, 200) + + # Verify access token is gone (explicit deletion in delete_user handles this) + fetch_cmd = ''' + SELECT COUNT(*) as cnt FROM access_token WHERE user_name = %s; + ''' + result = postgres.execute_fetch_command(fetch_cmd, ('token-cascade@example.com',), True) + self.assertEqual(result[0]['cnt'], 0) + + def test_access_token_expiration_validation(self): + """Test that access token expiration date must be in the future.""" + self._create_user(self.TEST_USER, roles=['osmo-user']) + + response = self.client.post( + '/api/auth/access_token/expired-token', + params={'expires_at': '2020-01-01'} # Past date + ) + self.assertEqual(response.status_code, 400) + self.assertIn('past the current date', response.json()['message']) + + def test_access_token_name_validation(self): + """Test that access token name must match valid regex.""" + self._create_user(self.TEST_USER, roles=['osmo-user']) + + response = self.client.post( + '/api/auth/access_token/invalid name with spaces', + params={'expires_at': '2027-01-01'} + ) + self.assertEqual(response.status_code, 400) + self.assertIn('must match regex', response.json()['message']) + + +if __name__ == '__main__': + runner.run_test() diff --git a/src/service/core/profile/BUILD b/src/service/core/profile/BUILD index abc17610e..af7cdfddf 100644 --- a/src/service/core/profile/BUILD +++ b/src/service/core/profile/BUILD @@ -28,6 +28,7 @@ osmo_py_library( deps = [ requirement("fastapi"), "//src/utils/connectors", + "//src/service/core/auth", "//src/service/core/workflow", ], visibility = ["//visibility:public"], diff --git a/src/service/core/profile/objects.py b/src/service/core/profile/objects.py index 7eddb1820..c72023867 100644 --- a/src/service/core/profile/objects.py +++ b/src/service/core/profile/objects.py @@ -1,5 +1,5 @@ """ -SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # pylint: disable=line-too-long Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ SPDX-License-Identifier: Apache-2.0 """ +import datetime from typing import List import pydantic @@ -23,7 +24,18 @@ from src.utils import connectors +class TokenIdentity(pydantic.BaseModel, extra=pydantic.Extra.forbid): + """ Identity when the request is authenticated with an access token. """ + name: str + expires_at: datetime.datetime | None = None # YYYY-MM-DD when token is found in DB + + class ProfileResponse(pydantic.BaseModel, extra=pydantic.Extra.forbid): - """ Object storing workflow name. """ + """ + Profile and identity info. When token header is set, roles/pools are the + token's; otherwise they are the user's. JSON is self-explanatory for CLI. + """ profile: connectors.UserProfile + roles: List[str] pools: List[str] + token: TokenIdentity | None = None diff --git a/src/service/core/profile/profile_service.py b/src/service/core/profile/profile_service.py index 34a09f892..0de102c6a 100644 --- a/src/service/core/profile/profile_service.py +++ b/src/service/core/profile/profile_service.py @@ -1,5 +1,5 @@ """ -SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # pylint: disable=line-too-long Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,7 +19,8 @@ import fastapi -from src.lib.utils import login +from src.lib.utils import login, osmo_errors +from src.service.core.auth import objects as auth_objects from src.service.core.profile import objects from src.utils import connectors @@ -34,14 +35,29 @@ def get_notification_settings( user_header: Optional[str] = fastapi.Header(alias=login.OSMO_USER_HEADER, default=None), roles_header: Optional[str] = - fastapi.Header(alias=login.OSMO_USER_ROLES, default=None) - ) -> objects.ProfileResponse: + fastapi.Header(alias=login.OSMO_USER_ROLES, default=None), + token_name_header: Optional[str] = + fastapi.Header(alias=login.OSMO_TOKEN_NAME_HEADER, default=None), +) -> objects.ProfileResponse: user_name = connectors.parse_username(user_header) postgres = connectors.PostgresConnector.get_instance() + roles = login.construct_roles_list(roles_header) + token_identity = None + if token_name_header: + expires_at = None + try: + expires_at = auth_objects.AccessToken.fetch_from_db( + postgres, token_name_header, user_name).expires_at + except osmo_errors.OSMOUserError: + pass + token_identity = objects.TokenIdentity( + name=token_name_header, expires_at=expires_at) return objects.ProfileResponse( profile=connectors.UserProfile.fetch_from_db(postgres, user_name), - pools=connectors.Pool.get_pools(login.construct_roles_list(roles_header)) - ) + roles=roles, + pools=connectors.Pool.get_pools(roles), + token=token_identity, + ) @router.post('/api/profile/settings') diff --git a/src/service/core/service.py b/src/service/core/service.py index bf6609521..2c5843905 100644 --- a/src/service/core/service.py +++ b/src/service/core/service.py @@ -1,5 +1,5 @@ """ -SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # pylint: disable=line-too-long Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ SPDX-License-Identifier: Apache-2.0 """ +import datetime import logging from pathlib import Path import sys @@ -33,7 +34,7 @@ from src.utils.metrics import metrics from src.service.agent import helpers as backend_helpers from src.service.core.app import app_service -from src.service.core.auth import auth_service +from src.service.core.auth import auth_service, objects as auth_objects from src.service.core.config import ( config_service, helpers as config_helpers, objects as config_objects ) @@ -280,6 +281,79 @@ def set_default_service_url(postgres: connectors.PostgresConnector): postgres.config.service_hostname) +def setup_default_admin(postgres: connectors.PostgresConnector, + config: objects.WorkflowServiceConfig): + """ + Set up the default admin user if configured. + + Creates a user with the osmo-admin role and an access_token with the + configured password. The access_token is stored hashed like other access_token keys. + + This is idempotent - if the user already exists, it will update the access_token. + """ + if not config.default_admin_username or not config.default_admin_password: + return + + admin_username = config.default_admin_username + admin_password = config.default_admin_password + token_name = 'default-admin-token' + + logging.info('Setting up default admin user: %s', admin_username) + + # Create or update the user + connectors.upsert_user(postgres, admin_username) + + # Assign the osmo-admin role if not already assigned + now = common.current_time() + assign_role_cmd = ''' + INSERT INTO user_roles (user_id, role_name, assigned_by, assigned_at) + VALUES (%s, %s, %s, %s) + ON CONFLICT (user_id, role_name) DO NOTHING; + ''' + postgres.execute_commit_command( + assign_role_cmd, (admin_username, 'osmo-admin', 'System', now)) + + # Check if token already exists and compare hashed values + check_token_cmd = ''' + SELECT access_token FROM access_token + WHERE user_name = %s AND token_name = %s; + ''' + existing_token = postgres.execute_fetch_command( + check_token_cmd, (admin_username, token_name), True) + + new_hashed_token = auth.hash_access_token(admin_password) + + if existing_token: + # Compare the hashed values - only update if different + existing_hashed_token = bytes(existing_token[0]['access_token']) + if existing_hashed_token == new_hashed_token: + logging.info( + 'Default admin user %s already configured with matching access_token', + admin_username) + return + + # Password has changed, delete the old token + logging.info('Default admin access_token password changed, updating token') + auth_objects.AccessToken.delete_from_db(postgres, token_name, admin_username) + + # Create the access_token with far future expiration (10 years) + # Use 10 years from now as the expiration date + expires_at = (datetime.datetime.now() + datetime.timedelta(days=3650)).strftime('%Y-%m-%d') + + auth_objects.AccessToken.insert_into_db( + database=postgres, + user_name=admin_username, + token_name=token_name, + access_token=admin_password, # This gets hashed inside insert_into_db + expires_at=expires_at, + description='Default admin access_token created during service initialization', + roles=['osmo-admin'], + assigned_by='System' + ) + + logging.info('Default admin user %s configured successfully with access_token', admin_username) + + def configure_app(target_app: fastapi.FastAPI, config: objects.WorkflowServiceConfig): src.lib.utils.logging.init_logger('service', config) @@ -318,6 +392,7 @@ def configure_app(target_app: fastapi.FastAPI, config: objects.WorkflowServiceCo create_default_pool(postgres) set_default_backend_images(postgres) set_default_service_url(postgres) + setup_default_admin(postgres, config) # Instantiate QueryParser query.QueryParser() diff --git a/src/service/core/workflow/objects.py b/src/service/core/workflow/objects.py index 453c17b58..e4aa7c790 100644 --- a/src/service/core/workflow/objects.py +++ b/src/service/core/workflow/objects.py @@ -1,5 +1,5 @@ """ -SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # pylint: disable=line-too-long Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -78,6 +78,31 @@ class WorkflowServiceConfig(connectors.RedisConfig, connectors.PostgresConfig, 'e.g. write to progress every 1 minute processed, like uploaded to DB). ' 'Format needs to be where unit can be either s (seconds) and ' 'm (minutes).') + default_admin_username: str | None = pydantic.Field( + command_line='default_admin_username', + env='OSMO_DEFAULT_ADMIN_USERNAME', + default=None, + description='The username for the default admin user to create on startup. ' + 'If set, default_admin_password must also be set.') + default_admin_password: str | None = pydantic.Field( + command_line='default_admin_password', + env='OSMO_DEFAULT_ADMIN_PASSWORD', + default=None, + description='The password (access token value) for the default admin user. ' + 'Must be set if default_admin_username is set.') + + @pydantic.root_validator() + @classmethod + def validate_default_admin(cls, values): + """ + Validate that if default_admin_username is set, default_admin_password must also be set + """ + username = values.get('default_admin_username') + password = values.get('default_admin_password') + if username and not password: + raise ValueError( + 'default_admin_password must be set when default_admin_username is specified') + return values class WorkflowServiceContext(pydantic.BaseModel): diff --git a/src/tests/common/database/postgres.py b/src/tests/common/database/postgres.py index 6edfa6652..726cc80b5 100644 --- a/src/tests/common/database/postgres.py +++ b/src/tests/common/database/postgres.py @@ -131,6 +131,18 @@ def setUp(self): postgres_instance.execute_commit_command( cmd, (extensions.AsIs(table),) * 4) + # Backup FK constraints (LIKE ... INCLUDING ALL doesn't copy FK constraints) + cmd = """ + SELECT + conname AS constraint_name, + conrelid::regclass::text AS table_name, + pg_get_constraintdef(oid) AS constraint_def + FROM pg_constraint + WHERE contype = 'f' + AND connamespace = 'public'::regnamespace; + """ + self.fk_constraints = postgres_instance.execute_fetch_command(cmd, (), True) + # Run database isolation step first before other setups super().setUp() @@ -174,6 +186,19 @@ def tearDown(self): """ postgres_instance.execute_commit_command(cmd, ()) + # Restore FK constraints (LIKE ... INCLUDING ALL doesn't copy FK constraints) + for fk in self.fk_constraints: + cmd = """ + ALTER TABLE %s + ADD CONSTRAINT %s %s; + """ + postgres_instance.execute_commit_command( + cmd, ( + extensions.AsIs(fk['table_name']), + extensions.AsIs(fk['constraint_name']), + extensions.AsIs(fk['constraint_def']) + )) + # Clean up backup schema cmd = 'DROP SCHEMA backup CASCADE;' postgres_instance.execute_commit_command(cmd, ()) diff --git a/src/utils/auth.py b/src/utils/auth.py index 0e0fdffd1..3308e770d 100644 --- a/src/utils/auth.py +++ b/src/utils/auth.py @@ -30,16 +30,6 @@ # The default length of time a token should be valid for. Defaults to 20 days DEFAULT_LENGTH = 20 * 24 * 60 * 60 -# The jinja template to use for adding additional claims to generated tokens -DEFAULT_TEMPLATE = ''' -{ - "unique_name": "{{username}}", - "osmo_workflow_push": "{{workflow_id}}", - "roles": [ - "osmo-user" - ] -} -''' class AsymmetricKeyPair(pydantic.BaseModel): @@ -140,6 +130,7 @@ def get_current_key(self) -> AsymmetricKeyPair: def create_idtoken_jwt(self, expire_timestamp: int, username: str, roles: List[str], + token_name: str | None = None, workflow_id: str | None = None) -> str: ''' aud: Audience @@ -161,9 +152,10 @@ def create_idtoken_jwt(self, expire_timestamp: int, username: str, 'unique_name': username, 'roles': roles } - # TODO: Remove this and create a new role per workflow_id + if token_name: + template_payload['osmo_token_name'] = token_name if workflow_id: - template_payload['osmo_workflow_push'] = workflow_id + template_payload['osmo_workflow_id'] = workflow_id # Substitute the template payload.update(template_payload) diff --git a/src/utils/connectors/postgres.py b/src/utils/connectors/postgres.py index bd902d566..1766a9c7e 100644 --- a/src/utils/connectors/postgres.py +++ b/src/utils/connectors/postgres.py @@ -672,11 +672,29 @@ def _init_tables(self): description TEXT, policies JSONB[], immutable BOOLEAN, + sync_mode TEXT NOT NULL DEFAULT 'import', PRIMARY KEY (name) ); """ self.execute_commit_command(create_cmd, ()) + # Creates table for role external mappings (many-to-many) + create_cmd = """ + CREATE TABLE IF NOT EXISTS role_external_mappings ( + role_name TEXT NOT NULL REFERENCES roles(name) ON DELETE CASCADE, + external_role TEXT NOT NULL, + PRIMARY KEY (role_name, external_role) + ); + """ + self.execute_commit_command(create_cmd, ()) + + # Create index for external role lookups + create_cmd = """ + CREATE INDEX IF NOT EXISTS idx_role_external_mappings_external_role + ON role_external_mappings (external_role); + """ + self.execute_commit_command(create_cmd, ()) + # Creates table for dynamic configs. create_cmd = ''' CREATE TABLE IF NOT EXISTS backends ( @@ -968,16 +986,6 @@ def _init_tables(self): ''' self.execute_commit_command(create_cmd, ()) - # Creates table for user keys. - create_cmd = ''' - CREATE TABLE IF NOT EXISTS ueks ( - uid TEXT, - keys HSTORE, - PRIMARY KEY (uid) - ); - ''' - self.execute_commit_command(create_cmd, ()) - create_cmd = ''' CREATE OR REPLACE FUNCTION jsonb_recursive_merge(receivingJson jsonb, givingJson jsonb) RETURNS jsonb LANGUAGE SQL AS $$ @@ -1089,18 +1097,12 @@ def _init_tables(self): ''' self.execute_commit_command(create_cmd, ()) - # Creates table for access token keys. + # Creates table for users (IDP users and service accounts) create_cmd = ''' - CREATE TABLE IF NOT EXISTS access_token ( - user_name TEXT, - token_name TEXT, - access_token BYTEA, - expires_at TIMESTAMP, - description TEXT, - access_type TEXT, - roles TEXT[], - PRIMARY KEY (user_name, token_name, access_type), - CONSTRAINT unique_access_token UNIQUE (access_token) + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + created_by TEXT ); ''' self.execute_commit_command(create_cmd, ()) @@ -1108,7 +1110,7 @@ def _init_tables(self): # Creates table for User profile create_cmd = ''' CREATE TABLE IF NOT EXISTS profile ( - user_name TEXT NOT NULL, + user_name TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, slack_notification BOOLEAN, email_notification BOOLEAN, bucket TEXT, @@ -1118,6 +1120,89 @@ def _init_tables(self): ''' self.execute_commit_command(create_cmd, ()) + # Creates table for user keys. + create_cmd = ''' + CREATE TABLE IF NOT EXISTS ueks ( + uid TEXT REFERENCES users(id) ON DELETE CASCADE, + keys HSTORE, + PRIMARY KEY (uid) + ); + ''' + self.execute_commit_command(create_cmd, ()) + + # Creates table for user role assignments + # Each assignment has a UUID that access_token_roles references for cascading deletes + create_cmd = ''' + CREATE TABLE IF NOT EXISTS user_roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_name TEXT NOT NULL REFERENCES roles(name) ON DELETE CASCADE, + assigned_by TEXT NOT NULL, + assigned_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + UNIQUE (user_id, role_name) + ); + ''' + self.execute_commit_command(create_cmd, ()) + + # Create indices for user_roles table + index_cmds = [ + ''' + CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_user_roles_user + ON user_roles (user_id); + ''', + ''' + CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_user_roles_role + ON user_roles (role_name); + ''' + ] + for cmd in index_cmds: + self.execute_autocommit_command(cmd, ()) + + # Creates table for access token keys (Personal Access Tokens). + create_cmd = ''' + CREATE TABLE IF NOT EXISTS access_token ( + user_name TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_name TEXT NOT NULL, + access_token BYTEA, + expires_at TIMESTAMP, + description TEXT, + last_seen_at TIMESTAMP WITH TIME ZONE, + PRIMARY KEY (user_name, token_name), + CONSTRAINT unique_access_token UNIQUE (access_token) + ); + ''' + self.execute_commit_command(create_cmd, ()) + + # Creates table for access_token role assignments (subset of user roles) + # References user_roles.id so access_token roles are auto-deleted when user loses a role + create_cmd = ''' + CREATE TABLE IF NOT EXISTS access_token_roles ( + user_name TEXT NOT NULL, + token_name TEXT NOT NULL, + user_role_id UUID NOT NULL REFERENCES user_roles(id) ON DELETE CASCADE, + assigned_by TEXT NOT NULL, + assigned_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_name, token_name, user_role_id), + FOREIGN KEY (user_name, token_name) + REFERENCES access_token(user_name, token_name) ON DELETE CASCADE + ); + ''' + self.execute_commit_command(create_cmd, ()) + + # Create indices for access_token_roles table + index_cmds = [ + ''' + CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_access_token_roles_token + ON access_token_roles (user_name, token_name); + ''', + ''' + CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_access_token_roles_user_role + ON access_token_roles (user_role_id); + ''' + ] + for cmd in index_cmds: + self.execute_autocommit_command(cmd, ()) + # Creates table for config history create_cmd = """ CREATE TABLE IF NOT EXISTS config_history ( @@ -1515,6 +1600,109 @@ def fetch_user_names(self, user_names: List[str]) -> List[str]: return [user_row['user_name'] for user_row in user_rows] +def upsert_user(database: PostgresConnector, user_name: str): + """ + Create a user in the users table if they don't exist. + If the user already exists, this is a no-op. + """ + upsert_cmd = ''' + INSERT INTO users (id, created_at, created_by) + VALUES (%s, NOW(), %s) + ON CONFLICT (id) DO NOTHING; + ''' + database.execute_commit_command(upsert_cmd, (user_name, user_name)) + + +def sync_user_roles(database: PostgresConnector, user_name: str, external_roles: List[str]): + """ + Synchronize user roles based on external IDP roles and each role's sync_mode. + + External roles from the header are mapped to OSMO roles via the role_external_mappings + table, then synced based on each role's sync_mode. + + Sync modes: + - ignore: Don't add or remove the role (managed manually) + - import: Add the role if user doesn't have it (but don't remove) + - force: Add if user doesn't have it, remove if user has it but it's not in header + + Args: + database: PostgresConnector instance + user_name: The username to sync roles for + external_roles: List of external role names from the IDP header + """ + # Single query to get all roles with sync_mode and whether they're mapped from external roles + # This combines role lookup, external mapping, and sync_mode in one query + fetch_roles_cmd = ''' + SELECT + r.name, + r.sync_mode, + EXISTS ( + SELECT 1 FROM role_external_mappings rem + WHERE rem.role_name = r.name AND rem.external_role = ANY(%s) + ) AS in_header + FROM roles r + WHERE r.sync_mode != %s; + ''' + all_roles = database.execute_fetch_command( + fetch_roles_cmd, + (external_roles if external_roles else [], role.SyncMode.IGNORE.value), + True + ) + if not all_roles: + return + + # Get user's current roles (only for roles we care about) + role_names = [r['name'] for r in all_roles] + fetch_user_roles_cmd = ''' + SELECT role_name FROM user_roles + WHERE user_id = %s AND role_name = ANY(%s); + ''' + user_role_rows = database.execute_fetch_command( + fetch_user_roles_cmd, (user_name, role_names), True + ) + current_user_roles = {row['role_name'] for row in user_role_rows} if user_role_rows else set() + + # Collect roles to add and remove for batch operations + roles_to_add = [] + roles_to_remove = [] + + for role_row in all_roles: + role_name = role_row['name'] + sync_mode = role_row['sync_mode'] + role_in_header = role_row['in_header'] + user_has_role = role_name in current_user_roles + + if sync_mode == role.SyncMode.IMPORT.value: + # Add the role if it's in the header and user doesn't have it + if role_in_header and not user_has_role: + roles_to_add.append(role_name) + + elif sync_mode == role.SyncMode.FORCE.value: + # Add if in header and user doesn't have it + if role_in_header and not user_has_role: + roles_to_add.append(role_name) + # Remove if user has it but it's not in header + elif user_has_role and not role_in_header: + roles_to_remove.append(role_name) + + # Batch insert roles to add + if roles_to_add: + insert_cmd = ''' + INSERT INTO user_roles (user_id, role_name, assigned_by, assigned_at) + SELECT %s, unnest(%s::text[]), %s, NOW() + ON CONFLICT (user_id, role_name) DO NOTHING; + ''' + database.execute_commit_command(insert_cmd, (user_name, roles_to_add, 'idp-sync')) + + # Batch delete roles to remove + if roles_to_remove: + delete_cmd = ''' + DELETE FROM user_roles + WHERE user_id = %s AND role_name = ANY(%s); + ''' + database.execute_commit_command(delete_cmd, (user_name, roles_to_remove)) + + class UserProfile(pydantic.BaseModel): """ Provides all User Profile Information """ username: str | None = None @@ -1540,6 +1728,9 @@ def default_profile(cls, user_name: str) -> 'UserProfile': def insert_into_db(cls, database: PostgresConnector, user_name: str, setting: Dict[str, Any]): + # Ensure user exists in users table before creating profile + upsert_user(database, user_name) + fields: List[str] = ['user_name'] values: List = [user_name] for key, value in setting.items(): @@ -3997,7 +4188,19 @@ def list_from_db(cls, database: PostgresConnector, names: Optional[List[str]] = fetch_cmd = f'SELECT * FROM roles {list_of_names} ORDER BY name;' spec_rows = database.execute_fetch_command(fetch_cmd, fetch_input, True) - return [cls(**spec_row) for spec_row in spec_rows] + if not spec_rows: + return [] + + # Batch fetch all external role mappings for these roles (avoid N+1 queries) + role_names = [row['name'] for row in spec_rows] + external_roles_map = cls._batch_fetch_external_roles(database, role_names) + + roles = [] + for spec_row in spec_rows: + spec_row['external_roles'] = external_roles_map.get(spec_row['name'], []) + roles.append(cls(**spec_row)) + + return roles @classmethod def fetch_from_db(cls, database: PostgresConnector, name: str) -> 'Role': @@ -4006,7 +4209,60 @@ def fetch_from_db(cls, database: PostgresConnector, name: str) -> 'Role': spec_rows = database.execute_fetch_command(fetch_cmd, (name,), True) if not spec_rows: raise osmo_errors.OSMOUserError(f'Role {name} does not exist.') - return cls(**spec_rows[0]) + + # Fetch external roles for this role + spec_row = spec_rows[0] + external_roles = cls._fetch_external_roles(database, name) + spec_row['external_roles'] = external_roles + + return cls(**spec_row) + + @classmethod + def _fetch_external_roles(cls, database: PostgresConnector, role_name: str) -> List[str]: + """ Fetches external role mappings for a given role """ + return cls._batch_fetch_external_roles(database, [role_name]).get(role_name, []) + + @classmethod + def _batch_fetch_external_roles(cls, database: PostgresConnector, + role_names: List[str]) -> Dict[str, List[str]]: + """ + Batch fetches external role mappings for multiple roles. + Returns a dict mapping role_name -> list of external roles. + """ + if not role_names: + return {} + + fetch_cmd = ''' + SELECT role_name, external_role FROM role_external_mappings + WHERE role_name = ANY(%s) + ORDER BY role_name, external_role; + ''' + rows = database.execute_fetch_command(fetch_cmd, (role_names,), True) + + # Group mappings by role_name + external_roles_map: Dict[str, List[str]] = {} + for row in rows: + external_roles_map.setdefault(row['role_name'], []).append(row['external_role']) + + return external_roles_map + + @classmethod + def get_roles_by_external_roles(cls, database: PostgresConnector, + external_roles: List[str]) -> List[str]: + """ + Fetches all OSMO role names that map to any of the given external roles. + Used during auth to map external roles from headers to OSMO roles. + """ + if not external_roles: + return [] + + fetch_cmd = ''' + SELECT DISTINCT role_name FROM role_external_mappings + WHERE external_role = ANY(%s) + ORDER BY role_name; + ''' + rows = database.execute_fetch_command(fetch_cmd, (external_roles,), True) + return [row['role_name'] for row in rows] @classmethod def delete_from_db(cls, database: PostgresConnector, name: str): @@ -4018,26 +4274,94 @@ def delete_from_db(cls, database: PostgresConnector, name: str): database.execute_commit_command(delete_cmd, (name,)) def insert_into_db(self, database: PostgresConnector, force: bool = False): - """ Create/update an entry in the roles table """ + """ + Create/update an entry in the roles table and sync external role mappings. + + This is a single atomic operation that: + 1. Inserts/updates the role in the roles table + 2. Synchronizes external role mappings based on external_roles value: + - None: Don't modify mappings (preserve existing), except for new roles + - []: Explicitly clear all mappings + - ['role1', ...]: Replace with specified mappings + For new roles with external_roles=None, creates a default mapping to the role name. + """ check_immutable = 'WHERE roles.immutable = false' if not force else '' + + # Determine sync parameters: + # - external_roles_provided: True if self.external_roles is not None + # - external_roles_list: the list to use + # (empty if None, to be replaced by default for new roles) + external_roles_provided = self.external_roles is not None + external_roles_list = self.external_roles if external_roles_provided else [] + + # Use CTEs to perform all operations atomically in a single transaction. + # The sync logic: + # - should_sync = external_roles_provided OR is_new_role + # - roles_to_map = external_roles_list if external_roles_provided else [role_name] (default) insert_cmd = f''' - INSERT INTO roles - (name, description, policies, immutable) - VALUES (%s, %s, %s::jsonb[], %s) - ON CONFLICT (name) - DO UPDATE SET - description = EXCLUDED.description, - policies = EXCLUDED.policies - {check_immutable} - RETURNING policies, immutable; + WITH role_upsert AS ( + INSERT INTO roles + (name, description, policies, immutable, sync_mode) + VALUES (%s, %s, %s::jsonb[], %s, %s) + ON CONFLICT (name) + DO UPDATE SET + description = EXCLUDED.description, + policies = EXCLUDED.policies, + sync_mode = EXCLUDED.sync_mode + {check_immutable} + RETURNING policies, immutable, (xmax = 0) AS is_new_role + ), + sync_config AS ( + SELECT + -- should_sync: True if external_roles explicitly provided OR if new role + (%s OR (SELECT is_new_role FROM role_upsert)) AS should_sync, + -- The roles to map: use provided list if external_roles was set, + -- otherwise use default (role name) for new roles + CASE + WHEN %s THEN %s::text[] + ELSE ARRAY[%s]::text[] + END AS roles_to_map, + (SELECT is_new_role FROM role_upsert) AS is_new_role + ), + delete_mappings AS ( + DELETE FROM role_external_mappings + WHERE role_name = %s + AND (SELECT should_sync FROM sync_config) + RETURNING 1 + ), + insert_mappings AS ( + INSERT INTO role_external_mappings (role_name, external_role) + SELECT %s, unnest((SELECT roles_to_map FROM sync_config)) + WHERE (SELECT should_sync FROM sync_config) + AND array_length((SELECT roles_to_map FROM sync_config), 1) > 0 + ON CONFLICT (role_name, external_role) DO NOTHING + RETURNING 1 + ) + SELECT policies, immutable, is_new_role FROM role_upsert; ''' + result = database.execute_fetch_command( insert_cmd, - (self.name, self.description, - [json.dumps(policy.to_dict()) for policy in self.policies], - False), + ( + # role_upsert params + self.name, + self.description, + [json.dumps(policy.to_dict()) for policy in self.policies], + False, + self.sync_mode.value, + # sync_config params + external_roles_provided, # first %s in sync_config (should_sync) + external_roles_provided, # WHEN %s in CASE + external_roles_list, # THEN %s::text[] + self.name, # ELSE ARRAY[%s] (default mapping) + # delete_mappings params + self.name, # WHERE role_name = %s + # insert_mappings params + self.name, # SELECT %s, unnest(...) + ), True ) + # No result means that immutable was true and nothing was updated if not force and (result and result[0].get('immutable') and \ result[0].get('policies', []) != [policy.to_dict() for policy in self.policies]): @@ -4129,5 +4453,3 @@ def insert_into_db(self, database: PostgresConnector, force: bool = False): immutable=True ), } - - diff --git a/src/utils/job/jobs.py b/src/utils/job/jobs.py index daaa6fcc3..87d757c2e 100644 --- a/src/utils/job/jobs.py +++ b/src/utils/job/jobs.py @@ -31,7 +31,6 @@ from typing import List, Dict, Tuple, Type import urllib.parse -import aiofiles import redis # type: ignore import redis.asyncio # type: ignore import pydantic diff --git a/src/utils/job/task.py b/src/utils/job/task.py index 0c99383cd..a68d72831 100644 --- a/src/utils/job/task.py +++ b/src/utils/job/task.py @@ -2290,7 +2290,7 @@ def convert_to_pod_spec( service_config.service_auth.ctrl_roles, workflow_id=self.workflow_id) - refresh_token = secrets.token_hex(REFRESH_TOKEN_LENGTH) + refresh_token = secrets.token_urlsafe(REFRESH_TOKEN_LENGTH) # Workaround for validation token_file = File(path='/token', contents=refresh_token)