From 7c9e163ec913cfbb0119aed1a22e6d7bee3de51e Mon Sep 17 00:00:00 2001 From: jogunjobi <4750385+jogunjobi@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:35:02 -0500 Subject: [PATCH] feat(admin): console user management with user types Administrators can now add and manage the people who sign in to the console, from a Console Users page and from /api/admin-users, instead of with SQL. - User types: Administrator (owner), Read-write (security_admin), Read-only (viewer), or Custom (chosen roles; never owner or admin). - A new user and a password reset get a generated temporary password, shown once, that must be changed at first sign-in. With SSO on, non-administrators sign in with SSO and get no password. - Edit type, roles, email and SSO subject; reset password; disable and enable; delete only once disabled. - An administrator cannot change, disable, reset or delete their own account here, and the last enabled administrator cannot be removed; changes are serialised with an advisory lock. - Role, enabled and password changes end the user's sessions through the existing authorization_version trigger. Every change is strictly audited as admin_user.*; no response or audit row carries a password or hash. Only owner and admin reach the new routes. The rules live in admin/admin_users.py, shared by the API and the pages. --- CHANGELOG.md | 8 + docs-site/public/admin-api/openapi.json | 938 ++++++++++++++++-- .../src/content/docs/concepts/admin-access.md | 5 +- .../guides/day-to-day/manage-admin-users.md | 93 +- .../src/content/docs/reference/admin-api.md | 18 + .../src/content/docs/reference/admin-roles.md | 4 + src/interlock/admin/admin_users.py | 504 ++++++++++ src/interlock/admin/app.py | 4 + src/interlock/admin/audit.py | 1 + src/interlock/admin/auth_middleware.py | 8 +- src/interlock/admin/routes/admin_users.py | 169 ++++ .../admin/routes/dashboard_admin_users.py | 254 +++++ src/interlock/admin/static/css/dashboard.css | 33 + src/interlock/admin/templates/base.html | 1 + .../templates/pages/admin_user_detail.html | 67 ++ .../templates/pages/admin_user_form.html | 16 + .../admin/templates/pages/admin_users.html | 64 ++ .../templates/partials/admin_user_error.html | 1 + .../templates/partials/admin_user_form.html | 77 ++ .../partials/admin_user_password.html | 24 + tests/browser/support.py | 2 + tests/e2e/test_admin_user_management.py | 174 ++++ tests/unit/admin_users_fake.py | 193 ++++ tests/unit/test_admin_users_routes.py | 303 ++++++ tests/unit/test_admin_users_service.py | 240 +++++ tests/unit/test_admin_ux_routes.py | 2 + tools/audit/mutate.py | 36 + 27 files changed, 3135 insertions(+), 104 deletions(-) create mode 100644 src/interlock/admin/admin_users.py create mode 100644 src/interlock/admin/routes/admin_users.py create mode 100644 src/interlock/admin/routes/dashboard_admin_users.py create mode 100644 src/interlock/admin/templates/pages/admin_user_detail.html create mode 100644 src/interlock/admin/templates/pages/admin_user_form.html create mode 100644 src/interlock/admin/templates/pages/admin_users.html create mode 100644 src/interlock/admin/templates/partials/admin_user_error.html create mode 100644 src/interlock/admin/templates/partials/admin_user_form.html create mode 100644 src/interlock/admin/templates/partials/admin_user_password.html create mode 100644 tests/e2e/test_admin_user_management.py create mode 100644 tests/unit/admin_users_fake.py create mode 100644 tests/unit/test_admin_users_routes.py create mode 100644 tests/unit/test_admin_users_service.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 36e7bea..9f26f59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,14 @@ record. Entries are grouped by the release that first shipped them. ### Added +- Console users can be managed from the console and the API. Administrators + open **Console Users** to add a user as Administrator, Read-write, Read-only + or Custom (chosen roles), edit their type, reset their password, disable, + enable and delete them. A new user, and a reset, gets a generated temporary + password shown once that must be changed at first sign-in. An administrator + cannot lock themselves out, the last enabled administrator cannot be removed, + and only a disabled user can be deleted. Every change is audited. Before, + adding a console user after deployment needed SQL. - A read-only console role, `viewer`: it opens every console page and read API and can change nothing, including dry-runs and connection tests. - When the console refuses an action, for example a read-only account clicking diff --git a/docs-site/public/admin-api/openapi.json b/docs-site/public/admin-api/openapi.json index 2ac24a9..d03e8a0 100644 --- a/docs-site/public/admin-api/openapi.json +++ b/docs-site/public/admin-api/openapi.json @@ -1,6 +1,101 @@ { "components": { "schemas": { + "AdminUserCreate": { + "properties": { + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Email" + }, + "roles": { + "description": "Roles for a custom user", + "items": { + "type": "string" + }, + "title": "Roles", + "type": "array" + }, + "sso_subject": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sso Subject" + }, + "user_type": { + "description": "administrator, read_write, read_only or custom", + "title": "User Type", + "type": "string" + }, + "username": { + "maxLength": 64, + "minLength": 1, + "title": "Username", + "type": "string" + } + }, + "required": [ + "username", + "user_type" + ], + "title": "AdminUserCreate", + "type": "object" + }, + "AdminUserUpdate": { + "properties": { + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Email" + }, + "roles": { + "description": "Roles for a custom user", + "items": { + "type": "string" + }, + "title": "Roles", + "type": "array" + }, + "sso_subject": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sso Subject" + }, + "user_type": { + "description": "administrator, read_write, read_only or custom", + "title": "User Type", + "type": "string" + } + }, + "required": [ + "user_type" + ], + "title": "AdminUserUpdate", + "type": "object" + }, "ApprovalActionResponse": { "properties": { "executed": { @@ -2163,42 +2258,53 @@ ] } }, - "/api/approvals": { + "/api/admin-users": { "get": { - "operationId": "list_approvals_api_approvals_get", - "parameters": [ - { - "in": "query", - "name": "status", - "required": false, - "schema": { - "anyOf": [ - { - "pattern": "^(pending|approved|rejected|expired|executed|failed)$", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Status" - } - } - ], + "description": "List console users.", + "operationId": "list_console_users_api_admin_users_get", "responses": { "200": { "content": { "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/ApprovalResponse" + "additionalProperties": true, + "type": "object" }, - "title": "Response List Approvals Api Approvals Get", + "title": "Response List Console Users Api Admin Users Get", "type": "array" } } }, "description": "Successful Response" + } + }, + "summary": "List Console Users", + "tags": [ + "admin-users" + ] + }, + "post": { + "description": "Create a console user; returns its temporary password once.", + "operationId": "create_console_user_api_admin_users_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminUserCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" }, "422": { "content": { @@ -2211,35 +2317,68 @@ "description": "Validation Error" } }, - "summary": "List Approvals", + "summary": "Create Console User", "tags": [ - "approvals" + "admin-users" ] } }, - "/api/approvals/{approval_id}": { - "get": { - "operationId": "get_approval_api_approvals__approval_id__get", + "/api/admin-users/{user_id}": { + "delete": { + "description": "Delete a disabled console user.", + "operationId": "delete_console_user_api_admin_users__user_id__delete", "parameters": [ { "in": "path", - "name": "approval_id", + "name": "user_id", "required": true, "schema": { - "title": "Approval Id", + "title": "User Id", "type": "integer" } } ], "responses": { - "200": { + "204": { + "description": "Successful Response" + }, + "422": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApprovalResponse" + "$ref": "#/components/schemas/HTTPValidationError" } } }, + "description": "Validation Error" + } + }, + "summary": "Delete Console User", + "tags": [ + "admin-users" + ] + }, + "get": { + "description": "Show one console user.", + "operationId": "get_console_user_api_admin_users__user_id__get", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "title": "User Id", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, "description": "Successful Response" }, "422": { @@ -2253,33 +2392,40 @@ "description": "Validation Error" } }, - "summary": "Get Approval", + "summary": "Get Console User", "tags": [ - "approvals" + "admin-users" ] - } - }, - "/api/approvals/{approval_id}/approve": { - "post": { - "operationId": "approve_approval_api_approvals__approval_id__approve_post", + }, + "put": { + "description": "Change a console user's type or roles, email and SSO subject.", + "operationId": "update_console_user_api_admin_users__user_id__put", "parameters": [ { "in": "path", - "name": "approval_id", + "name": "user_id", "required": true, "schema": { - "title": "Approval Id", + "title": "User Id", "type": "integer" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminUserUpdate" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/ApprovalActionResponse" - } + "schema": {} } }, "description": "Successful Response" @@ -2295,22 +2441,23 @@ "description": "Validation Error" } }, - "summary": "Approve Approval", + "summary": "Update Console User", "tags": [ - "approvals" + "admin-users" ] } }, - "/api/approvals/{approval_id}/reject": { + "/api/admin-users/{user_id}/disable": { "post": { - "operationId": "reject_approval_api_approvals__approval_id__reject_post", + "description": "Disable a console user and end their sessions.", + "operationId": "disable_console_user_api_admin_users__user_id__disable_post", "parameters": [ { "in": "path", - "name": "approval_id", + "name": "user_id", "required": true, "schema": { - "title": "Approval Id", + "title": "User Id", "type": "integer" } } @@ -2319,9 +2466,7 @@ "200": { "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/ApprovalActionResponse" - } + "schema": {} } }, "description": "Successful Response" @@ -2337,60 +2482,322 @@ "description": "Validation Error" } }, - "summary": "Reject Approval", + "summary": "Disable Console User", "tags": [ - "approvals" + "admin-users" ] } }, - "/api/catalog/naming-report": { - "get": { - "description": "Role statements and deny policies whose meaning catalog naming changes.", - "operationId": "catalog_naming_report_api_catalog_naming_report_get", + "/api/admin-users/{user_id}/enable": { + "post": { + "description": "Enable a disabled console user.", + "operationId": "enable_console_user_api_admin_users__user_id__enable_post", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "title": "User Id", + "type": "integer" + } + } + ], "responses": { "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { "content": { "application/json": { "schema": { - "additionalProperties": true, - "title": "Response Catalog Naming Report Api Catalog Naming Report Get", - "type": "object" + "$ref": "#/components/schemas/HTTPValidationError" } } }, - "description": "Successful Response" + "description": "Validation Error" } }, - "summary": "Catalog Naming Report", + "summary": "Enable Console User", "tags": [ - "catalog" + "admin-users" ] } }, - "/api/connectors": { - "get": { - "description": "Every connector a source can be registered on, with `active` and usage.", - "operationId": "list_connectors_api_connectors_get", + "/api/admin-users/{user_id}/reset-password": { + "post": { + "description": "Give a console user a new temporary password, shown once.", + "operationId": "reset_console_user_password_api_admin_users__user_id__reset_password_post", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "title": "User Id", + "type": "integer" + } + } + ], "responses": { "200": { "content": { "application/json": { - "schema": { - "items": { - "additionalProperties": true, - "type": "object" - }, - "title": "Response List Connectors Api Connectors Get", - "type": "array" - } + "schema": {} } }, "description": "Successful Response" - } - }, - "summary": "List Connectors", - "tags": [ - "connectors" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Reset Console User Password", + "tags": [ + "admin-users" + ] + } + }, + "/api/approvals": { + "get": { + "operationId": "list_approvals_api_approvals_get", + "parameters": [ + { + "in": "query", + "name": "status", + "required": false, + "schema": { + "anyOf": [ + { + "pattern": "^(pending|approved|rejected|expired|executed|failed)$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ApprovalResponse" + }, + "title": "Response List Approvals Api Approvals Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Approvals", + "tags": [ + "approvals" + ] + } + }, + "/api/approvals/{approval_id}": { + "get": { + "operationId": "get_approval_api_approvals__approval_id__get", + "parameters": [ + { + "in": "path", + "name": "approval_id", + "required": true, + "schema": { + "title": "Approval Id", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApprovalResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Approval", + "tags": [ + "approvals" + ] + } + }, + "/api/approvals/{approval_id}/approve": { + "post": { + "operationId": "approve_approval_api_approvals__approval_id__approve_post", + "parameters": [ + { + "in": "path", + "name": "approval_id", + "required": true, + "schema": { + "title": "Approval Id", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApprovalActionResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Approve Approval", + "tags": [ + "approvals" + ] + } + }, + "/api/approvals/{approval_id}/reject": { + "post": { + "operationId": "reject_approval_api_approvals__approval_id__reject_post", + "parameters": [ + { + "in": "path", + "name": "approval_id", + "required": true, + "schema": { + "title": "Approval Id", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApprovalActionResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Reject Approval", + "tags": [ + "approvals" + ] + } + }, + "/api/catalog/naming-report": { + "get": { + "description": "Role statements and deny policies whose meaning catalog naming changes.", + "operationId": "catalog_naming_report_api_catalog_naming_report_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Catalog Naming Report Api Catalog Naming Report Get", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Catalog Naming Report", + "tags": [ + "catalog" + ] + } + }, + "/api/connectors": { + "get": { + "description": "Every connector a source can be registered on, with `active` and usage.", + "operationId": "list_connectors_api_connectors_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Response List Connectors Api Connectors Get", + "type": "array" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "List Connectors", + "tags": [ + "connectors" ] } }, @@ -4227,6 +4634,373 @@ ] } }, + "/dashboard/admin-users": { + "get": { + "description": "Console users list.", + "operationId": "admin_users_page_dashboard_admin_users_get", + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Admin Users Page", + "tags": [ + "dashboard" + ] + } + }, + "/dashboard/admin-users/create": { + "post": { + "description": "Create a console user and show its temporary password once.", + "operationId": "create_admin_user_form_dashboard_admin_users_create_post", + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Create Admin User Form", + "tags": [ + "dashboard" + ] + } + }, + "/dashboard/admin-users/new": { + "get": { + "description": "New console user form.", + "operationId": "new_admin_user_form_dashboard_admin_users_new_get", + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "New Admin User Form", + "tags": [ + "dashboard" + ] + } + }, + "/dashboard/admin-users/{user_id}": { + "get": { + "description": "Console user detail.", + "operationId": "admin_user_detail_dashboard_admin_users__user_id__get", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "title": "User Id", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin User Detail", + "tags": [ + "dashboard" + ] + } + }, + "/dashboard/admin-users/{user_id}/delete": { + "post": { + "description": "Delete a disabled console user.", + "operationId": "delete_admin_user_dashboard_admin_users__user_id__delete_post", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "title": "User Id", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Delete Admin User", + "tags": [ + "dashboard" + ] + } + }, + "/dashboard/admin-users/{user_id}/disable": { + "post": { + "description": "Disable a console user.", + "operationId": "disable_admin_user_dashboard_admin_users__user_id__disable_post", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "title": "User Id", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Disable Admin User", + "tags": [ + "dashboard" + ] + } + }, + "/dashboard/admin-users/{user_id}/edit": { + "get": { + "description": "Edit console user form.", + "operationId": "edit_admin_user_form_dashboard_admin_users__user_id__edit_get", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "title": "User Id", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Edit Admin User Form", + "tags": [ + "dashboard" + ] + } + }, + "/dashboard/admin-users/{user_id}/enable": { + "post": { + "description": "Enable a console user.", + "operationId": "enable_admin_user_dashboard_admin_users__user_id__enable_post", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "title": "User Id", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Enable Admin User", + "tags": [ + "dashboard" + ] + } + }, + "/dashboard/admin-users/{user_id}/reset-password": { + "post": { + "description": "Give a console user a new temporary password.", + "operationId": "reset_admin_user_password_dashboard_admin_users__user_id__reset_password_post", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "title": "User Id", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Reset Admin User Password", + "tags": [ + "dashboard" + ] + } + }, + "/dashboard/admin-users/{user_id}/update": { + "post": { + "description": "Save a console user's type, roles, email and SSO subject.", + "operationId": "update_admin_user_form_dashboard_admin_users__user_id__update_post", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "title": "User Id", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Update Admin User Form", + "tags": [ + "dashboard" + ] + } + }, "/dashboard/alerts": { "get": { "operationId": "alerts_page_dashboard_alerts_get", diff --git a/docs-site/src/content/docs/concepts/admin-access.md b/docs-site/src/content/docs/concepts/admin-access.md index 9ff5d9b..30e13e6 100644 --- a/docs-site/src/content/docs/concepts/admin-access.md +++ b/docs-site/src/content/docs/concepts/admin-access.md @@ -21,7 +21,10 @@ from agent identities. Every admin action is recorded in the admin audit log. | `viewer` | read every console page and read API, and change nothing | An account can hold several roles. Which routes each role opens is listed in -[Admin roles](/reference/admin-roles/). +[Admin roles](/reference/admin-roles/). Administrators add console users from +**Console Users** in the sidebar, choosing a user type (Administrator, +Read-write, Read-only or Custom) rather than raw roles; see +[Manage admin users](/guides/day-to-day/manage-admin-users/). A `viewer` cannot run anything that uses stored credentials or belongs to an editing flow, even when it changes nothing: Test Connection, policy and diff --git a/docs-site/src/content/docs/guides/day-to-day/manage-admin-users.md b/docs-site/src/content/docs/guides/day-to-day/manage-admin-users.md index c0113e9..5e08883 100644 --- a/docs-site/src/content/docs/guides/day-to-day/manage-admin-users.md +++ b/docs-site/src/content/docs/guides/day-to-day/manage-admin-users.md @@ -1,44 +1,99 @@ --- title: Manage admin users -description: Add admin accounts, change passwords and use single sign-on. +description: Add, change, reset, disable and remove the people who sign in to the console. sidebar: order: 5 --- +Console users are the people who sign in to the admin console. They are +separate from agent identities, which connect to the gateway with an API key. +Only administrators (`owner` or `admin`) can manage console users. + ## Your own password Any admin can change their own password from **Change password** in the sidebar: at least 12 characters, not `admin`, not the username and not the current password. Changing it signs that account out everywhere else. -## Adding another admin +## Adding a console user + +Open **Console Users** in the sidebar and choose **+ New Console User**. Enter a +username (lowercase letters, digits and `. _ @ -`) and pick a user type: + +| User type | Roles | Can | +|---|---|---| +| Administrator | `owner` | everything, including managing console users | +| Read-write | `security_admin` | change sources, policies, agents and their keys, and review approvals; not console users | +| Read-only | `viewer` | open every page and read API; change nothing | +| Custom | any of `security_admin`, `source_admin`, `policy_admin`, `approval_reviewer`, `auditor`, `viewer` | what those roles allow, for example only reviewing approvals | -The console has no page for adding admin accounts yet. Until it does, add one in -the control database, with a temporary password it must change at first -sign-in. Generate the hash inside the admin container: +InterLock generates a temporary password and shows it **once**. Give it to the +person privately. When they first sign in they must choose their own password +before anything else opens. InterLock keeps only its hash, so if it is lost, +reset it. + +The same works through the API, as an administrator: + +```bash +curl -sS -b cookies.txt -H "X-CSRF-Token: $CSRF" -H 'Content-Type: application/json' \ + -X POST http://127.0.0.1:9090/api/admin-users \ + -d '{"username": "jane", "user_type": "read_only"}' +``` + +The response holds `temporary_password`, once. For a custom user, send +`"user_type": "custom"` and `"roles": ["approval_reviewer", "auditor"]`. + +## Changing, resetting and removing + +From a user's page: + +- **Edit** changes their type or roles, email and SSO subject. A role change + takes effect on their next request. +- **Reset password** gives them a new temporary password, shown once, and signs + them out. +- **Disable** signs them out at once and stops them signing in. **Enable** + reverses it. +- **Delete** is offered only for a disabled user. Audit records keep their + username. + +Two rules keep the console reachable. An administrator cannot change their own +type, disable, reset or delete their own account here; ask another +administrator. And the last enabled administrator can never be demoted, +disabled or deleted: add or enable another administrator first. + +Every change is recorded in the admin audit log as `admin_user.create`, +`update`, `reset_password`, `disable`, `enable` or `delete`. If the audit log +cannot be written, the request fails rather than going unrecorded. + +## Single sign-on + +With OIDC enabled, password sign-in is limited to administrators, as a +break-glass path. Everyone else signs in through your identity provider, so a +non-administrator console user needs their SSO subject (the `sub` claim): enter +it when adding them, and no password is created. An administrator added while +SSO is on still gets a temporary password for break-glass sign-in. + +At SSO sign-in, a user's roles are their own plus those mapped from their +identity-provider groups by `auth.oidc.admin_group_role_map`, so a group can +give a Read-only user more. Only administrators can set the SSO subject of a +console user, because that subject decides which account an SSO sign-in +becomes. + +## If no administrator can sign in + +If every administrator is locked out, an operator with access to the control +database can add one. Generate a password hash inside the admin container: ```bash docker compose exec admin python -c \ "from interlock.admin.auth import hash_password; print(hash_password('a-temporary-passphrase'))" ``` -Then insert the account, choosing its roles: +Then insert the account with a temporary password it must change: ```sql INSERT INTO admin_identities (username, password_hash, roles, enabled, must_change_password) -VALUES ('alex', '', ARRAY['source_admin'], TRUE, TRUE); +VALUES ('recovery', '', ARRAY['owner'], TRUE, TRUE); ``` -Give the temporary password to the person privately. Disable an account with -`UPDATE admin_identities SET enabled = FALSE WHERE username = 'alex'`; its -sessions end on their next request. - -## Single sign-on - -With OIDC enabled, admins sign in through your identity provider. Their roles -are the account's own roles plus those mapped from their groups by -`auth.oidc.admin_group_role_map`. An existing admin account is linked to an -identity-provider subject with `PUT /api/admin-auth/oidc/admins/{id}`. Password -sign-in can remain for `owner` and `admin` accounts as a break-glass path. - Roles are described in [Admin access](/concepts/admin-access/). diff --git a/docs-site/src/content/docs/reference/admin-api.md b/docs-site/src/content/docs/reference/admin-api.md index 743ade2..2ef164b 100644 --- a/docs-site/src/content/docs/reference/admin-api.md +++ b/docs-site/src/content/docs/reference/admin-api.md @@ -16,6 +16,14 @@ The console and its JSON API share one server. Every route except the public one | PUT | `/api/admin-auth/oidc/admins/{admin_id}` | owner, admin | | | DELETE | `/api/admin-auth/oidc/agents/{identity_id}` | owner, admin, security_admin | | | PUT | `/api/admin-auth/oidc/agents/{identity_id}` | owner, admin, security_admin | | +| GET | `/api/admin-users` | owner, admin | List console users. | +| POST | `/api/admin-users` | owner, admin | Create a console user; returns its temporary password once. | +| DELETE | `/api/admin-users/{user_id}` | owner, admin | Delete a disabled console user. | +| GET | `/api/admin-users/{user_id}` | owner, admin | Show one console user. | +| PUT | `/api/admin-users/{user_id}` | owner, admin | Change a console user's type or roles, email and SSO subject. | +| POST | `/api/admin-users/{user_id}/disable` | owner, admin | Disable a console user and end their sessions. | +| POST | `/api/admin-users/{user_id}/enable` | owner, admin | Enable a disabled console user. | +| POST | `/api/admin-users/{user_id}/reset-password` | owner, admin | Give a console user a new temporary password, shown once. | | GET | `/api/approvals` | owner, admin, approval_reviewer, security_admin, viewer | | | GET | `/api/approvals/{approval_id}` | owner, admin, approval_reviewer, security_admin, viewer | | | POST | `/api/approvals/{approval_id}/approve` | owner, admin, approval_reviewer, security_admin | | @@ -80,6 +88,16 @@ The console and its JSON API share one server. Every route except the public one | POST | `/dashboard/access-control/identities/{ident_id}/grants` | owner, admin, security_admin | Give an existing identity a role on a source, without re-keying it. | | POST | `/dashboard/access-control/identities/{ident_id}/grants/{grant_id}/revoke` | owner, admin, security_admin | Withdraw one grant. The key is untouched; the next request sees it. | | POST | `/dashboard/access-control/identities/{ident_id}/rotate-key` | owner, admin, security_admin | | +| GET | `/dashboard/admin-users` | owner, admin | Console users list. | +| POST | `/dashboard/admin-users/create` | owner, admin | Create a console user and show its temporary password once. | +| GET | `/dashboard/admin-users/new` | owner, admin | New console user form. | +| GET | `/dashboard/admin-users/{user_id}` | owner, admin | Console user detail. | +| POST | `/dashboard/admin-users/{user_id}/delete` | owner, admin | Delete a disabled console user. | +| POST | `/dashboard/admin-users/{user_id}/disable` | owner, admin | Disable a console user. | +| GET | `/dashboard/admin-users/{user_id}/edit` | owner, admin | Edit console user form. | +| POST | `/dashboard/admin-users/{user_id}/enable` | owner, admin | Enable a console user. | +| POST | `/dashboard/admin-users/{user_id}/reset-password` | owner, admin | Give a console user a new temporary password. | +| POST | `/dashboard/admin-users/{user_id}/update` | owner, admin | Save a console user's type, roles, email and SSO subject. | | GET | `/dashboard/alerts` | owner, admin, security_admin, viewer | | | POST | `/dashboard/alerts/create` | owner, admin, security_admin | | | POST | `/dashboard/alerts/dry-run` | owner, admin, security_admin | | diff --git a/docs-site/src/content/docs/reference/admin-roles.md b/docs-site/src/content/docs/reference/admin-roles.md index 2e9c4ec..45c53ec 100644 --- a/docs-site/src/content/docs/reference/admin-roles.md +++ b/docs-site/src/content/docs/reference/admin-roles.md @@ -23,6 +23,8 @@ An admin account holds one or more roles. `owner` and `admin` may do everything; | Path prefix | Roles besides owner and admin | |---|---| +| `/dashboard/admin-users` | owner, admin only | +| `/api/admin-users` | owner, admin only | | `/api/admin-auth/oidc/admins` | owner, admin only | | `/` | approval_reviewer, auditor, policy_admin, security_admin, source_admin, viewer | | `/openapi.json` | security_admin, viewer | @@ -62,6 +64,8 @@ Rules for `POST`, `PUT`, `PATCH` and `DELETE`. A change under `/dashboard` or `/ | Path prefix | Roles besides owner and admin | |---|---| +| `/dashboard/admin-users` | owner, admin only | +| `/api/admin-users` | owner, admin only | | `/api/admin-auth/oidc/admins` | owner, admin only | | `/dashboard/source-wizard` | security_admin, source_admin | | `/dashboard/data-sources` | security_admin, source_admin | diff --git a/src/interlock/admin/admin_users.py b/src/interlock/admin/admin_users.py new file mode 100644 index 0000000..0c9c470 --- /dev/null +++ b/src/interlock/admin/admin_users.py @@ -0,0 +1,504 @@ +"""Console users: the people who sign in to the admin console. + +One set of rules shared by the console pages and the JSON API, so neither can +drift from the other: + +- usernames are lowercase and unique regardless of case; +- a user's roles come from a user type (Administrator, Read-write, Read-only) + or a custom choice of the fine-grained roles; +- a new user, and a reset, gets a generated temporary password that is shown + once and must be changed at first sign-in; +- an administrator cannot change, disable, reset or delete their own account + here, and the last enabled administrator can never be removed; +- only a disabled account can be deleted. + +Role, enabled and password changes revoke the user's sessions on their next +request: a trigger bumps ``admin_identities.authorization_version`` and the +auth middleware compares it on every request. +""" + +from __future__ import annotations + +import re +import secrets +from contextlib import nullcontext +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from fastapi import Request + +from interlock.admin.audit import audit_admin_action, mutation_audit_detail +from interlock.admin.auth import hash_password +from interlock.admin.roles import ( + CUSTOM_ROLES, + CUSTOM_TYPE, + USER_TYPES, + classify_roles, + is_console_admin, + normalize_roles, +) + +USERNAME_RE = re.compile(r"^[a-z0-9][a-z0-9._@-]{2,63}$") +EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +# No look-alike characters (0/O, 1/l/I), so a password read aloud or copied by +# hand survives the trip: 54 symbols, 20 of them, about 115 bits. +_PASSWORD_ALPHABET = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789" +_PASSWORD_GROUPS = 4 +_PASSWORD_GROUP_LENGTH = 5 +_LOCK_SQL = "SELECT pg_advisory_xact_lock(hashtext('interlock.admin_users'))" +_COLUMNS = ( + "id, username, roles, enabled, email, oidc_subject, created_at, updated_at, " + "last_login_at, last_login_ip, must_change_password, password_changed_at, " + "(password_hash IS NOT NULL) AS password_set" +) +_OTHER_ENABLED_ADMINISTRATORS_SQL = """ + SELECT count(*) FROM admin_identities + WHERE enabled AND id <> $1 + AND EXISTS (SELECT 1 FROM unnest(roles) AS r WHERE lower(trim(r)) IN ('owner', 'admin')) +""" + + +class AdminUserError(Exception): + """A request about console users that cannot be carried out, and why.""" + + def __init__(self, code: str, message: str, status: int = 400) -> None: + super().__init__(message) + self.code = code + self.message = message + self.status = status + + +@dataclass(frozen=True) +class AdminUser: + id: int + username: str + roles: tuple[str, ...] + enabled: bool + email: str | None + sso_subject: str | None + created_at: datetime | None + updated_at: datetime | None + last_login_at: datetime | None + last_login_ip: str | None + must_change_password: bool + password_changed_at: datetime | None + password_set: bool = False + + @property + def user_type(self) -> str: + return classify_roles(self.roles)[0] + + @property + def type_label(self) -> str: + return classify_roles(self.roles)[1] + + @property + def is_administrator(self) -> bool: + return is_console_admin(self.roles) + + def to_public(self) -> dict[str, Any]: + """What the API returns. Never includes the password hash.""" + return { + "id": self.id, + "username": self.username, + "user_type": self.user_type, + "type_label": self.type_label, + "roles": list(self.roles), + "enabled": self.enabled, + "email": self.email, + "sso_subject": self.sso_subject, + "must_change_password": self.must_change_password, + "created_at": self.created_at, + "updated_at": self.updated_at, + "last_login_at": self.last_login_at, + "last_login_ip": self.last_login_ip, + "password_changed_at": self.password_changed_at, + "password_set": self.password_set, + } + + def audit_summary(self) -> dict[str, Any]: + return { + "id": self.id, + "username": self.username, + "roles": list(self.roles), + "enabled": self.enabled, + "email": self.email, + "sso_subject_configured": self.sso_subject is not None, + } + + +def _user(row: Any) -> AdminUser: + data = dict(row) + return AdminUser( + id=int(data["id"]), + username=str(data["username"]), + roles=tuple(data.get("roles") or ()), + enabled=bool(data.get("enabled")), + email=data.get("email"), + sso_subject=data.get("oidc_subject"), + created_at=data.get("created_at"), + updated_at=data.get("updated_at"), + last_login_at=data.get("last_login_at"), + last_login_ip=data.get("last_login_ip"), + must_change_password=bool(data.get("must_change_password")), + password_changed_at=data.get("password_changed_at"), + password_set=bool(data.get("password_set")), + ) + + +# --- validation --------------------------------------------------------------- + + +def normalize_username(raw: str) -> str: + username = (raw or "").strip().lower() + if not USERNAME_RE.match(username): + raise AdminUserError( + "invalid_username", + "Usernames are 3 to 64 characters: lowercase letters, digits, and . _ @ -, " + "starting with a letter or digit.", + ) + return username + + +def resolve_roles(user_type: str, custom_roles: list[str] | tuple[str, ...] = ()) -> list[str]: + """The roles to store for a user type, or for a custom choice of roles.""" + if user_type in USER_TYPES: + return list(USER_TYPES[user_type][1]) + if user_type != CUSTOM_TYPE: + raise AdminUserError("invalid_roles", f"Unknown user type: {user_type}.") + chosen = normalize_roles(custom_roles) + unknown = chosen - set(CUSTOM_ROLES) + if unknown: + raise AdminUserError("invalid_roles", "Unknown role: " + ", ".join(sorted(unknown)) + ".") + if not chosen: + raise AdminUserError("no_roles", "Choose at least one role for a custom user.") + return [role for role in CUSTOM_ROLES if role in chosen] + + +def normalize_email(raw: str | None) -> str | None: + email = (raw or "").strip() + if not email: + return None + if len(email) > 320 or not EMAIL_RE.match(email): + raise AdminUserError("invalid_email", "Enter a valid email address, or leave it empty.") + return email + + +def normalize_sso_subject(raw: str | None) -> str | None: + subject = raw or "" + if not subject.strip(): + return None + if subject != subject.strip() or len(subject) > 512 or any(ord(c) < 32 for c in subject): + raise AdminUserError( + "invalid_sso_subject", + "The SSO subject cannot have surrounding spaces or control characters.", + ) + return subject + + +def generate_temporary_password() -> str: + from interlock.admin.routes.password import password_change_problem + + while True: + groups = [ + "".join(secrets.choice(_PASSWORD_ALPHABET) for _ in range(_PASSWORD_GROUP_LENGTH)) + for _ in range(_PASSWORD_GROUPS) + ] + password = "-".join(groups) + if password_change_problem(username="", current="", new=password, confirm=password) is None: + return password + + +# --- guards ------------------------------------------------------------------- + + +def _assert_not_self(actor_id: int | None, user_id: int, action: str) -> None: + if actor_id is not None and actor_id == user_id: + raise AdminUserError( + "self_protected", + f"You cannot {action} your own account here. Ask another administrator, " + "or change your password from the sidebar.", + status=409, + ) + + +async def _assert_keeps_an_administrator(conn: Any, user: AdminUser) -> None: + """Refuse to leave the console without an enabled administrator.""" + if not (user.enabled and user.is_administrator): + return + others = await conn.fetchval(_OTHER_ENABLED_ADMINISTRATORS_SQL, user.id) + if not others: + raise AdminUserError( + "last_administrator", + "This is the last enabled administrator. Add or enable another administrator first.", + status=409, + ) + + +# --- reads ---------------------------------------------------------------------- + + +async def list_admin_users(pool: Any) -> list[AdminUser]: + async with pool.acquire() as conn: + rows = await conn.fetch(f"SELECT {_COLUMNS} FROM admin_identities ORDER BY lower(username)") + return [_user(row) for row in rows] + + +async def _fetch(conn: Any, user_id: int) -> AdminUser: + row = await conn.fetchrow(f"SELECT {_COLUMNS} FROM admin_identities WHERE id = $1", user_id) + if row is None: + raise AdminUserError("not_found", "Console user not found.", status=404) + return _user(row) + + +async def get_admin_user(pool: Any, user_id: int) -> AdminUser: + async with pool.acquire() as conn: + return await _fetch(conn, user_id) + + +class _Locked: + """A connection in a transaction that holds the console-users lock. + + The lock serialises changes, so two administrators cannot disable each + other at the same moment and leave none. + """ + + def __init__(self, pool: Any) -> None: + self._pool = pool + self._acquire: Any = None + self._tx: Any = None + + async def __aenter__(self) -> Any: + self._acquire = self._pool.acquire() + conn = await self._acquire.__aenter__() + self._tx = conn.transaction() if hasattr(conn, "transaction") else nullcontext() + await self._tx.__aenter__() + await conn.execute(_LOCK_SQL) + return conn + + async def __aexit__(self, *exc: Any) -> None: + try: + await self._tx.__aexit__(*exc) + finally: + await self._acquire.__aexit__(*exc) + + +async def _assert_unique( + conn: Any, username: str, sso_subject: str | None, exclude_id: int | None +) -> None: + taken = await conn.fetchval( + "SELECT 1 FROM admin_identities WHERE lower(username) = $1 AND id IS DISTINCT FROM $2", + username, + exclude_id, + ) + if taken: + raise AdminUserError("duplicate_username", "That username is already taken.", status=409) + if sso_subject is not None: + mapped = await conn.fetchval( + "SELECT 1 FROM admin_identities WHERE oidc_subject = $1 AND id IS DISTINCT FROM $2", + sso_subject, + exclude_id, + ) + if mapped: + raise AdminUserError( + "sso_subject_taken", + "That SSO subject already belongs to another console user.", + status=409, + ) + + +def _sso_requirement(sso_enabled: bool, roles: list[str], sso_subject: str | None) -> None: + # With SSO on, only administrators may sign in with a password (break-glass), + # so everyone else needs an SSO subject to be able to sign in at all. + if sso_enabled and not is_console_admin(roles) and sso_subject is None: + raise AdminUserError( + "sso_subject_required", + "Single sign-on is on, so this user signs in with SSO. Enter their SSO subject.", + ) + + +# --- changes -------------------------------------------------------------------- + + +async def create_admin_user( + pool: Any, + *, + username: str, + user_type: str, + custom_roles: list[str] | tuple[str, ...] = (), + email: str | None = None, + sso_subject: str | None = None, + sso_enabled: bool = False, +) -> tuple[AdminUser, str | None]: + """Create a console user. Returns the user and its temporary password, if any.""" + name = normalize_username(username) + roles = resolve_roles(user_type, custom_roles) + mail = normalize_email(email) + subject = normalize_sso_subject(sso_subject) + _sso_requirement(sso_enabled, roles, subject) + # With SSO on, only administrators get a password: it is their break-glass way in. + sso_only = sso_enabled and not is_console_admin(roles) + password = None if sso_only else generate_temporary_password() + async with _Locked(pool) as conn: + await _assert_unique(conn, name, subject, None) + row = await conn.fetchrow( + f""" + INSERT INTO admin_identities + (username, password_hash, roles, enabled, email, oidc_subject, must_change_password) + VALUES ($1, $2, $3, TRUE, $4, $5, $6) + RETURNING {_COLUMNS} + """, + name, + hash_password(password) if password else None, + roles, + mail, + subject, + password is not None, + ) + return _user(row), password + + +async def update_admin_user( + pool: Any, + *, + actor_id: int | None, + user_id: int, + user_type: str, + custom_roles: list[str] | tuple[str, ...] = (), + email: str | None = None, + sso_subject: str | None = None, + sso_enabled: bool = False, +) -> tuple[AdminUser, AdminUser]: + """Change a user's type or roles, email and SSO subject. Returns (before, after).""" + roles = resolve_roles(user_type, custom_roles) + mail = normalize_email(email) + subject = normalize_sso_subject(sso_subject) + async with _Locked(pool) as conn: + before = await _fetch(conn, user_id) + if normalize_roles(roles) != normalize_roles(before.roles): + _assert_not_self(actor_id, user_id, "change the type or roles of") + if not is_console_admin(roles): + await _assert_keeps_an_administrator(conn, before) + _sso_requirement(sso_enabled, roles, subject) + await _assert_unique(conn, before.username, subject, user_id) + row = await conn.fetchrow( + f""" + UPDATE admin_identities + SET roles = $2, email = $3, oidc_subject = $4, updated_at = NOW() + WHERE id = $1 + RETURNING {_COLUMNS} + """, + user_id, + roles, + mail, + subject, + ) + return before, _user(row) + + +async def set_enabled( + pool: Any, *, actor_id: int | None, user_id: int, enabled: bool +) -> tuple[AdminUser, AdminUser]: + """Disable or re-enable a user. Disabling ends their sessions at once.""" + async with _Locked(pool) as conn: + before = await _fetch(conn, user_id) + if not enabled: + _assert_not_self(actor_id, user_id, "disable") + await _assert_keeps_an_administrator(conn, before) + row = await conn.fetchrow( + f""" + UPDATE admin_identities SET enabled = $2, updated_at = NOW() + WHERE id = $1 + RETURNING {_COLUMNS} + """, + user_id, + enabled, + ) + return before, _user(row) + + +async def reset_password(pool: Any, *, actor_id: int | None, user_id: int) -> tuple[AdminUser, str]: + """Give a user a new temporary password, to be changed at their next sign-in.""" + _assert_not_self(actor_id, user_id, "reset the password of") + password = generate_temporary_password() + async with _Locked(pool) as conn: + await _fetch(conn, user_id) + row = await conn.fetchrow( + f""" + UPDATE admin_identities + SET password_hash = $2, must_change_password = TRUE, + password_changed_at = NULL, updated_at = NOW() + WHERE id = $1 + RETURNING {_COLUMNS} + """, + user_id, + hash_password(password), + ) + return _user(row), password + + +async def delete_admin_user(pool: Any, *, actor_id: int | None, user_id: int) -> AdminUser: + """Delete a disabled user. Audit records keep their username.""" + _assert_not_self(actor_id, user_id, "delete") + async with _Locked(pool) as conn: + before = await _fetch(conn, user_id) + if before.enabled: + raise AdminUserError( + "not_disabled", "Disable this user before deleting them.", status=409 + ) + await conn.execute("DELETE FROM admin_identities WHERE id = $1", user_id) + return before + + +# --- audit ------------------------------------------------------------------------ + + +def actor_id(request: Request) -> int | None: + admin = getattr(request.state, "admin", None) + value = getattr(admin, "admin_id", None) + return int(value) if value is not None else None + + +def sso_enabled(request: Request) -> bool: + config = getattr(request.app.state, "config", None) + auth = getattr(config, "auth", None) + return bool(getattr(getattr(auth, "oidc", None), "enabled", False)) + + +async def audit_change( + request: Request, + *, + action: str, + user: AdminUser, + before: AdminUser | None = None, + changed_fields: tuple[str, ...] = (), + status_code: int = 200, +) -> None: + """Record a console-user change. Fails closed: see admin/audit.py.""" + await audit_admin_action( + request, + action=f"admin_user.{action}", + resource="admin_user", + resource_id=str(user.id), + success=True, + detail=mutation_audit_detail( + before=before.audit_summary() if before else None, + after=user.audit_summary(), + changed_fields=changed_fields, + status_code=status_code, + ), + ) + + +def changed(before: AdminUser, after: AdminUser) -> tuple[str, ...]: + fields = [] + if normalize_roles(before.roles) != normalize_roles(after.roles): + fields.append("roles") + if before.email != after.email: + fields.append("email") + if before.sso_subject != after.sso_subject: + fields.append("sso_subject") + if before.enabled != after.enabled: + fields.append("enabled") + return tuple(fields) diff --git a/src/interlock/admin/app.py b/src/interlock/admin/app.py index 1a5f39f..8ffca3e 100644 --- a/src/interlock/admin/app.py +++ b/src/interlock/admin/app.py @@ -448,11 +448,13 @@ def create_app(config: InterLockConfig | None = None) -> FastAPI: app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") # --- routes --- + from interlock.admin.routes.admin_users import router as admin_users_router from interlock.admin.routes.approvals import router as approvals_router from interlock.admin.routes.auth import router as auth_router from interlock.admin.routes.catalog import router as catalog_router from interlock.admin.routes.connectors import router as connectors_router from interlock.admin.routes.dashboard import router as dashboard_router + from interlock.admin.routes.dashboard_admin_users import router as admin_users_pages_router from interlock.admin.routes.data_sources import router as ds_router from interlock.admin.routes.identities import router as id_router from interlock.admin.routes.ingestion import router as ingestion_router @@ -468,10 +470,12 @@ def create_app(config: InterLockConfig | None = None) -> FastAPI: app.include_router(approvals_router) app.include_router(ingestion_router) app.include_router(oidc_mappings_router) + app.include_router(admin_users_router) # Before the dashboard router, whose /dashboard/data-sources/{source_id}/... # routes would otherwise be tried first. app.include_router(catalog_router) app.include_router(connectors_router) + app.include_router(admin_users_pages_router) app.include_router(dashboard_router) # --- middleware --- diff --git a/src/interlock/admin/audit.py b/src/interlock/admin/audit.py index 144d60f..67c14e1 100644 --- a/src/interlock/admin/audit.py +++ b/src/interlock/admin/audit.py @@ -24,6 +24,7 @@ ) _STRICT_MUTATION_PREFIXES = ( + "admin_user.", "approval.", "data_source.", "identity.", diff --git a/src/interlock/admin/auth_middleware.py b/src/interlock/admin/auth_middleware.py index 68573bb..4519afb 100644 --- a/src/interlock/admin/auth_middleware.py +++ b/src/interlock/admin/auth_middleware.py @@ -77,8 +77,10 @@ _ADMIN_ONLY: frozenset[str] = frozenset() _ADMIN_MUTATION_RULES: tuple[tuple[str, frozenset[str]], ...] = ( - # SSO subjects decide which console account an SSO login becomes, so - # mapping them for console accounts is for administrators only. + # Console users, and the SSO subjects that decide which console account an + # SSO login becomes, are for administrators only. + ("/dashboard/admin-users", _ADMIN_ONLY), + ("/api/admin-users", _ADMIN_ONLY), ("/api/admin-auth/oidc/admins", _ADMIN_ONLY), ("/dashboard/source-wizard", frozenset({"source_admin", "security_admin"})), ("/dashboard/data-sources", frozenset({"source_admin", "security_admin"})), @@ -112,6 +114,8 @@ def _readable_by_viewer( _ADMIN_READ_RULES: tuple[tuple[str, frozenset[str]], ...] = _readable_by_viewer( ( + ("/dashboard/admin-users", _ADMIN_ONLY), + ("/api/admin-users", _ADMIN_ONLY), ("/api/admin-auth/oidc/admins", _ADMIN_ONLY), ("/", _ADMIN_OPERATOR_READ_ROLES), ("/openapi.json", frozenset({"security_admin"})), diff --git a/src/interlock/admin/routes/admin_users.py b/src/interlock/admin/routes/admin_users.py new file mode 100644 index 0000000..50b352f --- /dev/null +++ b/src/interlock/admin/routes/admin_users.py @@ -0,0 +1,169 @@ +"""JSON API for console users: the people who sign in to the admin console. + +Administrators only (owner or admin); the auth middleware enforces that. The +rules live in interlock.admin.admin_users and are shared with the console pages. +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse, Response +from pydantic import BaseModel, Field + +from interlock.admin import admin_users as users + +router = APIRouter(prefix="/api/admin-users", tags=["admin-users"]) + + +class AdminUserCreate(BaseModel): + username: str = Field(min_length=1, max_length=64) + user_type: str = Field(description="administrator, read_write, read_only or custom") + roles: list[str] = Field(default_factory=list, description="Roles for a custom user") + email: str | None = None + sso_subject: str | None = None + + +class AdminUserUpdate(BaseModel): + user_type: str = Field(description="administrator, read_write, read_only or custom") + roles: list[str] = Field(default_factory=list, description="Roles for a custom user") + email: str | None = None + sso_subject: str | None = None + + +def _error(exc: users.AdminUserError) -> JSONResponse: + return JSONResponse({"error": exc.code, "detail": exc.message}, status_code=exc.status) + + +@router.get("") +async def list_console_users(request: Request) -> list[dict[str, Any]]: + """List console users.""" + return [user.to_public() for user in await users.list_admin_users(request.app.state.pg_pool)] + + +@router.post("", status_code=201, response_model=None) +async def create_console_user(body: AdminUserCreate, request: Request) -> Any: + """Create a console user; returns its temporary password once.""" + try: + user, password = await users.create_admin_user( + request.app.state.pg_pool, + username=body.username, + user_type=body.user_type, + custom_roles=body.roles, + email=body.email, + sso_subject=body.sso_subject, + sso_enabled=users.sso_enabled(request), + ) + except users.AdminUserError as exc: + return _error(exc) + await users.audit_change(request, action="create", user=user, status_code=201) + return JSONResponse( + {"user": jsonable(user.to_public()), "temporary_password": password}, + status_code=201, + headers={"Cache-Control": "no-store"}, + ) + + +@router.get("/{user_id}", response_model=None) +async def get_console_user(user_id: int, request: Request) -> Any: + """Show one console user.""" + try: + user = await users.get_admin_user(request.app.state.pg_pool, user_id) + except users.AdminUserError as exc: + return _error(exc) + return user.to_public() + + +@router.put("/{user_id}", response_model=None) +async def update_console_user(user_id: int, body: AdminUserUpdate, request: Request) -> Any: + """Change a console user's type or roles, email and SSO subject.""" + try: + before, after = await users.update_admin_user( + request.app.state.pg_pool, + actor_id=users.actor_id(request), + user_id=user_id, + user_type=body.user_type, + custom_roles=body.roles, + email=body.email, + sso_subject=body.sso_subject, + sso_enabled=users.sso_enabled(request), + ) + except users.AdminUserError as exc: + return _error(exc) + await users.audit_change( + request, + action="update", + user=after, + before=before, + changed_fields=users.changed(before, after), + ) + return after.to_public() + + +@router.post("/{user_id}/reset-password", response_model=None) +async def reset_console_user_password(user_id: int, request: Request) -> Any: + """Give a console user a new temporary password, shown once.""" + try: + user, password = await users.reset_password( + request.app.state.pg_pool, actor_id=users.actor_id(request), user_id=user_id + ) + except users.AdminUserError as exc: + return _error(exc) + await users.audit_change(request, action="reset_password", user=user) + return JSONResponse( + {"user": jsonable(user.to_public()), "temporary_password": password}, + headers={"Cache-Control": "no-store"}, + ) + + +async def _set_enabled(request: Request, user_id: int, enabled: bool) -> Any: + try: + before, after = await users.set_enabled( + request.app.state.pg_pool, + actor_id=users.actor_id(request), + user_id=user_id, + enabled=enabled, + ) + except users.AdminUserError as exc: + return _error(exc) + await users.audit_change( + request, + action="enable" if enabled else "disable", + user=after, + before=before, + changed_fields=users.changed(before, after), + ) + return after.to_public() + + +@router.post("/{user_id}/disable", response_model=None) +async def disable_console_user(user_id: int, request: Request) -> Any: + """Disable a console user and end their sessions.""" + return await _set_enabled(request, user_id, False) + + +@router.post("/{user_id}/enable", response_model=None) +async def enable_console_user(user_id: int, request: Request) -> Any: + """Enable a disabled console user.""" + return await _set_enabled(request, user_id, True) + + +@router.delete("/{user_id}", status_code=204, response_model=None) +async def delete_console_user(user_id: int, request: Request) -> Any: + """Delete a disabled console user.""" + try: + before = await users.delete_admin_user( + request.app.state.pg_pool, actor_id=users.actor_id(request), user_id=user_id + ) + except users.AdminUserError as exc: + return _error(exc) + await users.audit_change(request, action="delete", user=before, before=before, status_code=204) + return Response(status_code=204) + + +def jsonable(data: dict[str, Any]) -> dict[str, Any]: + from fastapi.encoders import jsonable_encoder + + result: dict[str, Any] = jsonable_encoder(data) + return result diff --git a/src/interlock/admin/routes/dashboard_admin_users.py b/src/interlock/admin/routes/dashboard_admin_users.py new file mode 100644 index 0000000..483df87 --- /dev/null +++ b/src/interlock/admin/routes/dashboard_admin_users.py @@ -0,0 +1,254 @@ +"""Console pages for console users. Administrators only (see auth_middleware).""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse, Response + +from interlock.admin import admin_users as users +from interlock.admin.roles import CUSTOM_ROLES, CUSTOM_TYPE, ROLE_MEANINGS +from interlock.admin.routes.dashboard import _mutation_redirect, _render, _render_page + +router = APIRouter(tags=["dashboard"]) + +_USER_TYPES = ( + ("administrator", "Administrator", "Everything, including adding and removing console users."), + ( + "read_write", + "Read-write", + "Change sources, policies, agents and their keys, and review approvals. " + "Cannot manage console users.", + ), + ("read_only", "Read-only", "See every page; change nothing."), + (CUSTOM_TYPE, "Custom", "Pick specific roles, for example only reviewing approvals."), +) +_EMPTY_FORM: dict[str, Any] = { + "id": None, + "username": "", + "user_type": "read_only", + "roles": [], + "email": "", + "sso_subject": "", +} + + +def _form_context( + request: Request, mode: str, form: dict[str, Any], error: str = "" +) -> dict[str, Any]: + return { + "active_page": "admin-users", + "mode": mode, + "form": form, + "error": error, + "user_types": _USER_TYPES, + "custom_roles": [(role, ROLE_MEANINGS[role]) for role in CUSTOM_ROLES], + "sso_enabled": users.sso_enabled(request), + } + + +async def _posted(request: Request) -> dict[str, Any]: + data = await request.form() + return { + "username": str(data.get("username") or ""), + "user_type": str(data.get("user_type") or ""), + "roles": [str(role) for role in data.getlist("roles")], + "email": str(data.get("email") or ""), + "sso_subject": str(data.get("sso_subject") or ""), + } + + +def _error(request: Request, exc: users.AdminUserError) -> HTMLResponse: + # 200 so htmx swaps the message into the action slot. + return _render(request, "partials/admin_user_error.html", {"error": exc.message}) + + +def _password_reveal( + request: Request, heading: str, user: users.AdminUser, password: str | None +) -> HTMLResponse: + response = _render( + request, + "partials/admin_user_password.html", + {"heading": heading, "user": user, "password": password}, + ) + response.headers["Cache-Control"] = "no-store" + return response + + +@router.get("/dashboard/admin-users", response_class=HTMLResponse) +async def admin_users_page(request: Request) -> HTMLResponse: + """Console users list.""" + ctx = { + "active_page": "admin-users", + "users": await users.list_admin_users(request.app.state.pg_pool), + "current_admin_id": users.actor_id(request), + } + return _render_page(request, "pages/admin_users.html", "pages/admin_users.html", ctx) + + +@router.get("/dashboard/admin-users/new", response_class=HTMLResponse) +async def new_admin_user_form(request: Request) -> HTMLResponse: + """New console user form.""" + ctx = _form_context(request, "new", dict(_EMPTY_FORM)) + return _render_page(request, "pages/admin_user_form.html", "partials/admin_user_form.html", ctx) + + +@router.post("/dashboard/admin-users/create", response_class=HTMLResponse) +async def create_admin_user_form(request: Request) -> HTMLResponse: + """Create a console user and show its temporary password once.""" + form = await _posted(request) + try: + user, password = await users.create_admin_user( + request.app.state.pg_pool, + username=form["username"], + user_type=form["user_type"], + custom_roles=form["roles"], + email=form["email"], + sso_subject=form["sso_subject"], + sso_enabled=users.sso_enabled(request), + ) + except users.AdminUserError as exc: + return _render( + request, + "partials/admin_user_form.html", + _form_context(request, "new", form, exc.message), + ) + await users.audit_change(request, action="create", user=user, status_code=201) + return _password_reveal(request, "Console user created", user, password) + + +@router.get("/dashboard/admin-users/{user_id}", response_class=HTMLResponse) +async def admin_user_detail(request: Request, user_id: int) -> HTMLResponse: + """Console user detail.""" + try: + user = await users.get_admin_user(request.app.state.pg_pool, user_id) + except users.AdminUserError: + return _render_page( + request, + "pages/not_found.html", + "pages/not_found.html", + {"active_page": "admin-users", "what": "Console user"}, + ) + ctx = { + "active_page": "admin-users", + "user": user, + "is_self": user.id == users.actor_id(request), + } + return _render_page( + request, "pages/admin_user_detail.html", "pages/admin_user_detail.html", ctx + ) + + +@router.get("/dashboard/admin-users/{user_id}/edit", response_class=HTMLResponse) +async def edit_admin_user_form(request: Request, user_id: int) -> HTMLResponse: + """Edit console user form.""" + try: + user = await users.get_admin_user(request.app.state.pg_pool, user_id) + except users.AdminUserError: + return await admin_user_detail(request, user_id) + form = { + "id": user.id, + "username": user.username, + "user_type": user.user_type, + "roles": [role for role in user.roles], + "email": user.email or "", + "sso_subject": user.sso_subject or "", + } + ctx = _form_context(request, "edit", form) + return _render_page(request, "pages/admin_user_form.html", "partials/admin_user_form.html", ctx) + + +@router.post("/dashboard/admin-users/{user_id}/update", response_class=HTMLResponse) +async def update_admin_user_form(request: Request, user_id: int) -> Response: + """Save a console user's type, roles, email and SSO subject.""" + form = await _posted(request) + try: + before, after = await users.update_admin_user( + request.app.state.pg_pool, + actor_id=users.actor_id(request), + user_id=user_id, + user_type=form["user_type"], + custom_roles=form["roles"], + email=form["email"], + sso_subject=form["sso_subject"], + sso_enabled=users.sso_enabled(request), + ) + except users.AdminUserError as exc: + try: + current = await users.get_admin_user(request.app.state.pg_pool, user_id) + form["username"] = current.username + except users.AdminUserError: + pass + form["id"] = user_id + return _render( + request, + "partials/admin_user_form.html", + _form_context(request, "edit", form, exc.message), + ) + await users.audit_change( + request, + action="update", + user=after, + before=before, + changed_fields=users.changed(before, after), + ) + return _mutation_redirect(request, f"/dashboard/admin-users/{user_id}") + + +@router.post("/dashboard/admin-users/{user_id}/reset-password", response_class=HTMLResponse) +async def reset_admin_user_password(request: Request, user_id: int) -> HTMLResponse: + """Give a console user a new temporary password.""" + try: + user, password = await users.reset_password( + request.app.state.pg_pool, actor_id=users.actor_id(request), user_id=user_id + ) + except users.AdminUserError as exc: + return _error(request, exc) + await users.audit_change(request, action="reset_password", user=user) + return _password_reveal(request, "Password reset", user, password) + + +async def _set_enabled(request: Request, user_id: int, enabled: bool) -> Response: + try: + before, after = await users.set_enabled( + request.app.state.pg_pool, + actor_id=users.actor_id(request), + user_id=user_id, + enabled=enabled, + ) + except users.AdminUserError as exc: + return _error(request, exc) + await users.audit_change( + request, + action="enable" if enabled else "disable", + user=after, + before=before, + changed_fields=users.changed(before, after), + ) + return _mutation_redirect(request, f"/dashboard/admin-users/{user_id}") + + +@router.post("/dashboard/admin-users/{user_id}/disable", response_class=HTMLResponse) +async def disable_admin_user(request: Request, user_id: int) -> Response: + """Disable a console user.""" + return await _set_enabled(request, user_id, False) + + +@router.post("/dashboard/admin-users/{user_id}/enable", response_class=HTMLResponse) +async def enable_admin_user(request: Request, user_id: int) -> Response: + """Enable a console user.""" + return await _set_enabled(request, user_id, True) + + +@router.post("/dashboard/admin-users/{user_id}/delete", response_class=HTMLResponse) +async def delete_admin_user(request: Request, user_id: int) -> Response: + """Delete a disabled console user.""" + try: + before = await users.delete_admin_user( + request.app.state.pg_pool, actor_id=users.actor_id(request), user_id=user_id + ) + except users.AdminUserError as exc: + return _error(request, exc) + await users.audit_change(request, action="delete", user=before, before=before, status_code=303) + return _mutation_redirect(request, "/dashboard/admin-users") diff --git a/src/interlock/admin/static/css/dashboard.css b/src/interlock/admin/static/css/dashboard.css index 742ed69..10338be 100644 --- a/src/interlock/admin/static/css/dashboard.css +++ b/src/interlock/admin/static/css/dashboard.css @@ -2308,3 +2308,36 @@ body.login-body { .admin-notice strong { color: var(--status-error); } .sidebar-account-badge { margin-left: auto; flex: none; } + +/* Console user form: user types as cards, custom roles shown only for Custom. */ +.user-types, +.custom-roles { + border: 0; + margin: 0 0 var(--space-4); + padding: 0; + display: grid; + gap: var(--space-2); +} +.user-types legend, +.custom-roles legend { + font-weight: 600; + margin-bottom: var(--space-2); +} +.user-type { + display: flex; + gap: var(--space-3); + align-items: flex-start; + padding: var(--space-3) var(--space-4); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + cursor: pointer; +} +.user-type:has(input:checked) { + border-color: var(--accent-bar); + background: var(--accent-quiet); +} +.user-type > span { display: grid; gap: var(--space-1); min-width: 0; } +.user-type input { margin-top: 0.2rem; } +.custom-roles { display: none; } +.admin-user-form:has(input[name="user_type"][value="custom"]:checked) .custom-roles { display: grid; } +.custom-roles .checkbox span { min-width: 0; } diff --git a/src/interlock/admin/templates/base.html b/src/interlock/admin/templates/base.html index fe362a5..fbfd4da 100644 --- a/src/interlock/admin/templates/base.html +++ b/src/interlock/admin/templates/base.html @@ -77,6 +77,7 @@
  • Identities
  • Policies
  • + {% if is_console_admin %}
  • Console Users
  • {% endif %}
  • Ingestion
  • diff --git a/src/interlock/admin/templates/pages/admin_user_detail.html b/src/interlock/admin/templates/pages/admin_user_detail.html new file mode 100644 index 0000000..3d6e7e4 --- /dev/null +++ b/src/interlock/admin/templates/pages/admin_user_detail.html @@ -0,0 +1,67 @@ +{% if not content_only %}{% extends "base.html" %}{% endif %} +{% if content_only %}{% endif %}{% block title %}{{ user.username }} - Console User - InterLock{% endblock %}{% if content_only %}{% endif %} + +{% block content %} + + +{% if is_self %} +

    This is your account. Another administrator can change its type, reset its password or disable it; change your own password from the sidebar.

    +{% endif %} + +
    + +
    +
    +

    Console user

    +
    +
    Username
    {{ user.username }}
    +
    Type
    {{ user.type_label }}
    +
    Roles
    {% for role in user.roles %}{{ role }}{% else %}None{% endfor %}
    +
    Status
    + {% if user.enabled %} enabled{% else %} disabled{% endif %} +
    +
    Signs in with
    {% if user.sso_subject %}SSO{% if user.password_set %} and a password{% endif %}{% elif user.password_set %}a password{% else %}nothing yet{% endif %}
    +
    Password
    + {% if not user.password_set %}None + {% elif user.must_change_password %}Temporary: must be changed at next sign-in + {% elif user.password_changed_at %}Changed {{ user.password_changed_at|fmt_dt }} + {% else %}Set{% endif %} +
    +
    Email
    {{ user.email or '-' }}
    +
    SSO subject
    {% if user.sso_subject %}{{ user.sso_subject }}{% else %}-{% endif %}
    +
    Last sign-in
    {% if user.last_login_at %}{{ user.last_login_at|fmt_dt }}{% if user.last_login_ip %} from {{ user.last_login_ip }}{% endif %}{% else %}Never{% endif %}
    +
    Created
    {{ user.created_at|fmt_dt if user.created_at else '-' }}
    +
    +
    +
    +{% endblock %} diff --git a/src/interlock/admin/templates/pages/admin_user_form.html b/src/interlock/admin/templates/pages/admin_user_form.html new file mode 100644 index 0000000..adf8fd3 --- /dev/null +++ b/src/interlock/admin/templates/pages/admin_user_form.html @@ -0,0 +1,16 @@ +{% if not content_only %}{% extends "base.html" %}{% endif %} +{% if content_only %}{% endif %}{% block title %}{% if mode == 'edit' %}Edit {{ form.username }}{% else %}New Console User{% endif %} - InterLock Dashboard{% endblock %}{% if content_only %}{% endif %} + +{% block content %} + + +
    + {% include "partials/admin_user_form.html" %} +
    +{% endblock %} diff --git a/src/interlock/admin/templates/pages/admin_users.html b/src/interlock/admin/templates/pages/admin_users.html new file mode 100644 index 0000000..2038a19 --- /dev/null +++ b/src/interlock/admin/templates/pages/admin_users.html @@ -0,0 +1,64 @@ +{% if not content_only %}{% extends "base.html" %}{% endif %} +{% if content_only %}{% endif %}{% block title %}Console Users - InterLock Dashboard{% endblock %}{% if content_only %}{% endif %} + +{% block content %} + + +
    +
    +
    +

    Who can sign in to this console

    +

    People, not agents. Agents connect with an identity's API key; see Identities.

    +
    +
    +
    + + + + + + + + + + + + {% for user in users %} + + + + + + + + {% else %} + + {% endfor %} + +
    UsernameTypeStatusSign-inLast sign-in
    + {{ user.username }} + {% if user.id == current_admin_id %}(you){% endif %} + {{ user.type_label }} + {% if not user.enabled %} + Disabled + {% elif user.must_change_password %} + Must change password + {% else %} + Active + {% endif %} + {% if user.sso_subject %}SSO{% if user.password_set %} and password{% endif %}{% elif user.password_set %}Password{% else %}None{% endif %}{{ user.last_login_at|fmt_dt if user.last_login_at else 'Never' }}
    No console users.
    +
    +
    +{% endblock %} diff --git a/src/interlock/admin/templates/partials/admin_user_error.html b/src/interlock/admin/templates/partials/admin_user_error.html new file mode 100644 index 0000000..10b5ced --- /dev/null +++ b/src/interlock/admin/templates/partials/admin_user_error.html @@ -0,0 +1 @@ + diff --git a/src/interlock/admin/templates/partials/admin_user_form.html b/src/interlock/admin/templates/partials/admin_user_form.html new file mode 100644 index 0000000..9e3af90 --- /dev/null +++ b/src/interlock/admin/templates/partials/admin_user_form.html @@ -0,0 +1,77 @@ +{# New or edit console user. The Custom roles list is revealed with CSS :has(), + because the console's CSP allows no inline script. #} +
    +

    {% if mode == 'edit' %}Edit console user{% else %}New console user{% endif %}

    + {% if error %} + + {% endif %} + +
    + {% if mode == 'edit' %} +
    Username
    {{ form.username }}
    + {% else %} +
    + +
    +

    Lowercase letters, digits, and . _ @ -. This is what they type to sign in.

    + {% endif %} + +
    + User type + {% for key, label, help in user_types %} + + {% endfor %} +
    + +
    + Roles for a custom user + {% for role, meaning in custom_roles %} + + {% endfor %} +
    + +

    Single sign-on (optional)

    + {% if sso_enabled %} +

    Single sign-on is on. Everyone except administrators signs in with SSO, so enter their SSO subject. Administrators also get a temporary password for break-glass sign-in.

    + {% else %} +

    Single sign-on is off; these are kept for when it is turned on.

    + {% endif %} +
    + + +
    + +
    + {% if mode == 'edit' %} + + Cancel + {% else %} + + Cancel + {% endif %} +
    +
    +
    diff --git a/src/interlock/admin/templates/partials/admin_user_password.html b/src/interlock/admin/templates/partials/admin_user_password.html new file mode 100644 index 0000000..85d5e5e --- /dev/null +++ b/src/interlock/admin/templates/partials/admin_user_password.html @@ -0,0 +1,24 @@ +{# One-time temporary password. Only its hash is stored, so this is the only + chance to copy it. #} +
    +

    {{ heading }}

    + {% if password %} +

    Give {{ user.username }} this temporary password. It is shown + once; they must choose a new one when they first sign in.

    +
    + {{ password }} + +
    + {% else %} +

    {{ user.username }} signs in with single sign-on. No password was created.

    + {% endif %} +
    +
    Username
    {{ user.username }}
    +
    Type
    {{ user.type_label }}
    + {% if user.sso_subject %}
    SSO subject
    {{ user.sso_subject }}
    {% endif %} +
    +
    + Done +
    +
    diff --git a/tests/browser/support.py b/tests/browser/support.py index acd3bc4..f14880a 100644 --- a/tests/browser/support.py +++ b/tests/browser/support.py @@ -15,6 +15,7 @@ ("source-roles", "/dashboard/data-sources/e2e_pg/roles", "Source Roles"), ("identities", "/dashboard/access-control/identities", "Identities"), ("policies", "/dashboard/policies", "Policies"), + ("admin-users", "/dashboard/admin-users", "Console Users"), ("ingestion", "/dashboard/ingestion", "Ingestion Jobs"), ("workers", "/dashboard/workers", "Worker Status"), ("discovery", "/dashboard/discovery", "Discovery"), @@ -37,6 +38,7 @@ ("data-source-new", "/dashboard/data-sources/new", "New"), ("identity-new", "/dashboard/access-control/identities/new", "New Identity"), ("policy-new", "/dashboard/policies/new", "New Policy"), + ("admin-user-new", "/dashboard/admin-users/new", "New Console User"), ("alert-new", "/dashboard/alerts/new", "New Alert"), ("source-role-new", "/dashboard/data-sources/e2e_pg/roles/new", "Role"), ) diff --git a/tests/e2e/test_admin_user_management.py b/tests/e2e/test_admin_user_management.py new file mode 100644 index 0000000..5ef50ae --- /dev/null +++ b/tests/e2e/test_admin_user_management.py @@ -0,0 +1,174 @@ +"""Console users, end to end against the running admin service. + +An administrator adds a Read-only and a Read-write user through the API. Each +signs in with the one-time temporary password, is held on the change-password +page, chooses a password, and then finds the console open to exactly what +their type allows. Disabling a user ends their open session at once, and a +user can be deleted only once disabled. +""" + +from __future__ import annotations + +import secrets +from collections.abc import Iterator +from typing import Any + +import httpx +import pytest + +pytestmark = [pytest.mark.e2e] + +POLICY_PROBE = {"conditions": {"tables": ["customers"]}, "actions": {"effect": "deny"}} + + +def _csrf(client: httpx.Client) -> str: + response = client.get("/auth/csrf") + response.raise_for_status() + return str(response.json()["csrf"]) + + +def _sign_in_and_choose_password(base_url: str, username: str, temporary: str) -> httpx.Client: + client = httpx.Client(base_url=base_url, follow_redirects=False, timeout=10) + login = client.post( + "/auth/login", + data={"username": username, "password": temporary}, + headers={"Accept": "application/json"}, + ) + assert login.status_code == 200, login.text + assert login.json().get("password_change_required") is True + + held = client.get("/api/policies", headers={"Accept": "application/json"}) + assert held.status_code == 403 and held.json()["error"] == "password_change_required" + + chosen = "e2e-console-user-" + secrets.token_hex(6) + change = client.post( + "/auth/change-password", + data={"current_password": temporary, "new_password": chosen, "confirm_password": chosen}, + headers={"X-CSRF-Token": _csrf(client), "Accept": "application/json"}, + ) + assert change.status_code == 200, change.text + return client + + +@pytest.fixture +def owner(admin_session: Any) -> Iterator[tuple[httpx.Client, dict[str, str]]]: + yield admin_session.client, {"X-CSRF-Token": admin_session.csrf_token} + + +def _create( + owner: tuple[httpx.Client, dict[str, str]], user_type: str, **extra: Any +) -> dict[str, Any]: + client, headers = owner + username = f"e2e-{user_type.replace('_', '-')}-{secrets.token_hex(3)}" + response = client.post( + "/api/admin-users", + json={"username": username, "user_type": user_type, **extra}, + headers=headers, + ) + assert response.status_code == 201, response.text + assert response.headers.get("cache-control") == "no-store" + body: dict[str, Any] = response.json() + return body + + +def _cleanup(owner: tuple[httpx.Client, dict[str, str]], user_id: int) -> None: + client, headers = owner + client.post(f"/api/admin-users/{user_id}/disable", headers=headers) + client.delete(f"/api/admin-users/{user_id}", headers=headers) + + +def test_read_only_user_reads_everything_and_changes_nothing(owner: Any, e2e_config: Any) -> None: + body = _create(owner, "read_only") + user = body["user"] + try: + assert user["type_label"] == "Read-only" and user["must_change_password"] is True + reader = _sign_in_and_choose_password( + e2e_config.admin_url, user["username"], body["temporary_password"] + ) + + for page in ( + "/dashboard/policies", + "/dashboard/access-control/identities", + "/dashboard/data-sources", + ): + assert reader.get(page).status_code == 200, page + assert reader.get("/api/policies").status_code == 200 + + headers = {"X-CSRF-Token": _csrf(reader), "Accept": "application/json"} + write = reader.post("/api/policies/validate", json=POLICY_PROBE, headers=headers) + assert write.status_code == 403 + assert reader.get("/api/admin-users").status_code == 403 + assert reader.get("/dashboard/admin-users").status_code == 403 + finally: + _cleanup(owner, user["id"]) + + +def test_read_write_user_changes_things_but_not_console_users(owner: Any, e2e_config: Any) -> None: + body = _create(owner, "read_write") + user = body["user"] + try: + writer = _sign_in_and_choose_password( + e2e_config.admin_url, user["username"], body["temporary_password"] + ) + headers = {"X-CSRF-Token": _csrf(writer), "Accept": "application/json"} + + assert ( + writer.post("/api/policies/validate", json=POLICY_PROBE, headers=headers).status_code + == 200 + ) + assert writer.get("/api/admin-users").status_code == 403 + escalate = writer.post( + "/api/admin-users", + json={"username": "e2e-sneaky", "user_type": "administrator"}, + headers=headers, + ) + assert escalate.status_code == 403 + mapping = writer.put( + f"/api/admin-auth/oidc/admins/{user['id']}", json={"subject": "x"}, headers=headers + ) + assert mapping.status_code == 403 + finally: + _cleanup(owner, user["id"]) + + +def test_disable_ends_the_session_and_delete_needs_disable_first( + owner: Any, e2e_config: Any +) -> None: + client, headers = owner + body = _create(owner, "custom", roles=["auditor"]) + user = body["user"] + try: + auditor = _sign_in_and_choose_password( + e2e_config.admin_url, user["username"], body["temporary_password"] + ) + assert auditor.get("/dashboard/audit-costs").status_code == 200 + + refused = client.delete(f"/api/admin-users/{user['id']}", headers=headers) + assert refused.status_code == 409 and refused.json()["error"] == "not_disabled" + + disabled = client.post(f"/api/admin-users/{user['id']}/disable", headers=headers) + assert disabled.status_code == 200 and disabled.json()["enabled"] is False + after = auditor.get("/dashboard/audit-costs", headers={"Accept": "application/json"}) + assert after.status_code == 401 + + assert client.delete(f"/api/admin-users/{user['id']}", headers=headers).status_code == 204 + assert client.get(f"/api/admin-users/{user['id']}").status_code == 404 + finally: + _cleanup(owner, user["id"]) + + +def test_reset_password_forces_a_new_choice(owner: Any, e2e_config: Any) -> None: + client, headers = owner + body = _create(owner, "read_only") + user = body["user"] + try: + _sign_in_and_choose_password( + e2e_config.admin_url, user["username"], body["temporary_password"] + ) + reset = client.post(f"/api/admin-users/{user['id']}/reset-password", headers=headers) + assert reset.status_code == 200 and reset.json()["user"]["must_change_password"] is True + _sign_in_and_choose_password( + e2e_config.admin_url, user["username"], reset.json()["temporary_password"] + ) + finally: + _cleanup(owner, user["id"]) diff --git a/tests/unit/admin_users_fake.py b/tests/unit/admin_users_fake.py new file mode 100644 index 0000000..bb1bf0b --- /dev/null +++ b/tests/unit/admin_users_fake.py @@ -0,0 +1,193 @@ +"""An in-memory admin_identities table that answers the SQL admin_users.py sends.""" + +from __future__ import annotations + +import re +from datetime import UTC, datetime +from typing import Any + +COLUMNS = ( + "id", + "username", + "roles", + "enabled", + "email", + "oidc_subject", + "created_at", + "updated_at", + "last_login_at", + "last_login_ip", + "must_change_password", + "password_changed_at", +) + + +class FakeAdminUsersDB: + def __init__(self) -> None: + self.rows: dict[int, dict[str, Any]] = {} + self.audit: list[tuple[Any, ...]] = [] + self.locks = 0 + self._next = 1 + + def add(self, username: str, roles: list[str], *, enabled: bool = True, **extra: Any) -> int: + row_id = self._next + self._next += 1 + now = datetime.now(UTC) + self.rows[row_id] = { + "id": row_id, + "username": username, + "roles": list(roles), + "enabled": enabled, + "email": None, + "oidc_subject": None, + "created_at": now, + "updated_at": now, + "last_login_at": None, + "last_login_ip": None, + "must_change_password": False, + "password_changed_at": None, + "password_hash": "scrypt$stored", + "authorization_version": 1, + **extra, + } + return row_id + + def _public(self, row: dict[str, Any]) -> dict[str, Any]: + out = {key: row[key] for key in COLUMNS} + out["password_set"] = row.get("password_hash") is not None + return out + + # -- pool ------------------------------------------------------------------- + + def acquire(self) -> _Acquire: + return _Acquire(_Conn(self)) + + async def fetchrow(self, sql: str, *args: Any) -> dict[str, Any] | None: + # The auth middleware's per-request session check. + if "SELECT enabled, authorization_version FROM admin_identities" in " ".join(sql.split()): + row = self.rows.get(args[0]) + return ( + {"enabled": row["enabled"], "authorization_version": row["authorization_version"]} + if row + else None + ) + return await _Conn(self).fetchrow(sql, *args) + + async def execute(self, sql: str, *args: Any) -> str: + if "INSERT INTO admin_audit_log" in sql: + self.audit.append(args) + return "INSERT 0 1" + return await _Conn(self).execute(sql, *args) + + +class _Acquire: + def __init__(self, conn: _Conn) -> None: + self.conn = conn + + async def __aenter__(self) -> _Conn: + return self.conn + + async def __aexit__(self, *exc: Any) -> None: + return None + + +class _Tx: + async def __aenter__(self) -> None: + return None + + async def __aexit__(self, *exc: Any) -> None: + return None + + +class _Conn: + def __init__(self, db: FakeAdminUsersDB) -> None: + self.db = db + + def transaction(self) -> _Tx: + return _Tx() + + async def execute(self, sql: str, *args: Any) -> str: + if "pg_advisory_xact_lock" in sql: + self.db.locks += 1 + return "SELECT 1" + if sql.strip().startswith("DELETE FROM admin_identities"): + self.db.rows.pop(args[0], None) + return "DELETE 1" + raise AssertionError(f"unexpected execute: {sql}") + + async def fetch(self, sql: str, *args: Any) -> list[dict[str, Any]]: + assert "FROM admin_identities ORDER BY lower(username)" in sql + assert "password_hash," not in sql.replace("(password_hash IS NOT NULL)", "") + rows = sorted(self.db.rows.values(), key=lambda r: r["username"].lower()) + return [self.db._public(r) for r in rows] + + async def fetchval(self, sql: str, *args: Any) -> Any: + if "lower(username) = $1" in sql: + name, exclude = args + return next( + ( + 1 + for r in self.db.rows.values() + if r["username"].lower() == name and r["id"] != exclude + ), + None, + ) + if "oidc_subject = $1" in sql: + subject, exclude = args + return next( + ( + 1 + for r in self.db.rows.values() + if r["oidc_subject"] == subject and r["id"] != exclude + ), + None, + ) + if "count(*)" in sql: + (exclude,) = args + return sum( + 1 + for r in self.db.rows.values() + if r["enabled"] + and r["id"] != exclude + and any(role.strip().lower() in {"owner", "admin"} for role in r["roles"]) + ) + raise AssertionError(f"unexpected fetchval: {sql}") + + async def fetchrow(self, sql: str, *args: Any) -> dict[str, Any] | None: + text = " ".join(sql.split()) + if text.startswith("SELECT") and "WHERE id = $1" in text: + row = self.db.rows.get(args[0]) + return self.db._public(row) if row else None + if text.startswith("INSERT INTO admin_identities"): + username, password_hash, roles, email, subject, must_change = args + row_id = self.db.add( + username, + roles, + email=email, + oidc_subject=subject, + must_change_password=must_change, + password_hash=password_hash, + ) + return self.db._public(self.db.rows[row_id]) + if text.startswith("UPDATE admin_identities"): + row = self.db.rows.get(args[0]) + if row is None: + return None + watched = ("roles", "enabled", "oidc_subject", "password_hash") + old = {key: row[key] for key in watched} + if "SET roles = $2" in text: + row.update(roles=list(args[1]), email=args[2], oidc_subject=args[3]) + elif "SET enabled = $2" in text: + row["enabled"] = args[1] + elif "SET password_hash = $2" in text: + row.update( + password_hash=args[1], must_change_password=True, password_changed_at=None + ) + else: + raise AssertionError(f"unexpected update: {text}") + # Migration 019's trigger: a real change ends the user's sessions. + if any(old[key] != row[key] for key in watched): + row["authorization_version"] += 1 + assert re.search(r"RETURNING id, username", text) + return self.db._public(row) + raise AssertionError(f"unexpected fetchrow: {text}") diff --git a/tests/unit/test_admin_users_routes.py b/tests/unit/test_admin_users_routes.py new file mode 100644 index 0000000..8d701d4 --- /dev/null +++ b/tests/unit/test_admin_users_routes.py @@ -0,0 +1,303 @@ +"""Console users through the API and the console pages, with real sessions and RBAC.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +from httpx import ASGITransport, AsyncClient + +from interlock.admin.app import create_app +from interlock.admin.auth import create_session, sign_cookie +from interlock.config import InterLockConfig +from tests.unit.admin_users_fake import FakeAdminUsersDB +from tests.unit.test_p0_f_admin_auth import FakeRedis + +SECRET = "console-users-test-secret-not-for-prod" + + +def _app(db: FakeAdminUsersDB, redis: FakeRedis, *, sso: bool = False) -> Any: + config = InterLockConfig() + config.admin.secret_key = SECRET + config.auth.oidc.enabled = sso + with ( + patch("interlock.admin.app.create_pg_pool", new_callable=AsyncMock) as cp, + patch("interlock.admin.app.close_pg_pool", new_callable=AsyncMock), + patch("interlock.admin.app.create_redis_client", new_callable=AsyncMock) as cr, + patch("interlock.admin.app.close_redis_client", new_callable=AsyncMock), + ): + cp.return_value = db + cr.return_value = redis + app = create_app(config=config) + app.state.pg_pool = db + app.state.redis = redis + app.state.admin_secret_key = SECRET + return app + + +class Session: + def __init__(self, client: AsyncClient, csrf: str) -> None: + self.client = client + self.csrf = csrf + + async def get(self, path: str, **kw: Any) -> Any: + return await self.client.get(path, **kw) + + async def send(self, method: str, path: str, *, htmx: bool = False, **kw: Any) -> Any: + headers = {"x-csrf-token": self.csrf, **kw.pop("headers", {})} + if htmx: + headers["HX-Request"] = "true" + return await self.client.request(method, path, headers=headers, **kw) + + +async def _session(app: Any, db: FakeAdminUsersDB, redis: FakeRedis, admin_id: int) -> Session: + row = db.rows[admin_id] + sid, csrf = await create_session( + redis, + admin_id=admin_id, + username=row["username"], + roles=row["roles"], + authorization_version=row["authorization_version"], + ttl_seconds=3600, + ) + client = AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + follow_redirects=False, + cookies={InterLockConfig().admin.cookie_name: sign_cookie({"sid": sid}, SECRET)}, + ) + return Session(client, csrf) + + +@pytest.fixture +def db() -> FakeAdminUsersDB: + store = FakeAdminUsersDB() + store.add("admin", ["owner"]) # id 1 + return store + + +# --- API ---------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_an_administrator_adds_a_read_only_user(db: FakeAdminUsersDB) -> None: + redis = FakeRedis() + app = _app(db, redis) + owner = await _session(app, db, redis, 1) + + resp = await owner.send( + "POST", "/api/admin-users", json={"username": "Jane", "user_type": "read_only"} + ) + + assert resp.status_code == 201, resp.text + assert resp.headers["cache-control"] == "no-store" + body = resp.json() + assert body["user"]["username"] == "jane" + assert body["user"]["type_label"] == "Read-only" + assert body["user"]["roles"] == ["viewer"] + assert body["user"]["must_change_password"] is True + assert len(body["temporary_password"]) == 23 + assert "password_hash" not in resp.text + audits = [args for args in db.audit if args[2] == "admin_user.create"] + assert len(audits) == 1 and body["temporary_password"] not in str(audits) + + listed = await owner.get("/api/admin-users") + assert [u["username"] for u in listed.json()] == ["admin", "jane"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("roles", [["security_admin"], ["viewer"], ["auditor"], ["policy_admin"]]) +async def test_only_administrators_reach_console_users( + db: FakeAdminUsersDB, roles: list[str] +) -> None: + redis = FakeRedis() + app = _app(db, redis) + other = await _session(app, db, redis, db.add("someone", roles)) + + assert (await other.get("/api/admin-users")).status_code == 403 + assert (await other.get("/dashboard/admin-users")).status_code == 403 + created = await other.send( + "POST", "/api/admin-users", json={"username": "x1y", "user_type": "administrator"} + ) + assert created.status_code == 403 + assert len(db.rows) == 2 + + +@pytest.mark.asyncio +async def test_disabling_a_user_ends_their_open_session(db: FakeAdminUsersDB) -> None: + redis = FakeRedis() + app = _app(db, redis) + owner = await _session(app, db, redis, 1) + reader_id = db.add("reader", ["viewer"]) + reader = await _session(app, db, redis, reader_id) + assert (await reader.get("/api/catalog/search?q=x")).status_code != 401 + + resp = await owner.send("POST", f"/api/admin-users/{reader_id}/disable") + assert resp.status_code == 200 and resp.json()["enabled"] is False + + assert (await reader.get("/api/catalog/search?q=x")).status_code == 401 + + +@pytest.mark.asyncio +async def test_delete_is_refused_until_the_user_is_disabled(db: FakeAdminUsersDB) -> None: + redis = FakeRedis() + app = _app(db, redis) + owner = await _session(app, db, redis, 1) + target = db.add("temp", ["viewer"]) + + refused = await owner.send("DELETE", f"/api/admin-users/{target}") + assert refused.status_code == 409 and refused.json()["error"] == "not_disabled" + + await owner.send("POST", f"/api/admin-users/{target}/disable") + assert (await owner.send("DELETE", f"/api/admin-users/{target}")).status_code == 204 + assert target not in db.rows + + +@pytest.mark.asyncio +async def test_an_administrator_cannot_lock_themselves_out(db: FakeAdminUsersDB) -> None: + redis = FakeRedis() + app = _app(db, redis) + owner = await _session(app, db, redis, 1) + + resp = await owner.send("POST", "/api/admin-users/1/disable") + assert resp.status_code == 409 and resp.json()["error"] == "self_protected" + demote = await owner.send("PUT", "/api/admin-users/1", json={"user_type": "read_only"}) + assert demote.status_code == 409 + assert db.rows[1]["enabled"] and db.rows[1]["roles"] == ["owner"] + + +@pytest.mark.asyncio +async def test_reset_password_returns_a_new_temporary_password_once(db: FakeAdminUsersDB) -> None: + redis = FakeRedis() + app = _app(db, redis) + owner = await _session(app, db, redis, 1) + target = db.add("jane", ["security_admin"]) + + resp = await owner.send("POST", f"/api/admin-users/{target}/reset-password") + + assert resp.status_code == 200 and resp.headers["cache-control"] == "no-store" + assert resp.json()["user"]["must_change_password"] is True + assert len(resp.json()["temporary_password"]) == 23 + + +@pytest.mark.asyncio +async def test_validation_errors_come_back_as_codes(db: FakeAdminUsersDB) -> None: + redis = FakeRedis() + app = _app(db, redis) + owner = await _session(app, db, redis, 1) + + dup = await owner.send( + "POST", "/api/admin-users", json={"username": "ADMIN", "user_type": "read_only"} + ) + assert dup.status_code == 409 and dup.json()["error"] == "duplicate_username" + sneaky = await owner.send( + "POST", + "/api/admin-users", + json={"username": "bob", "user_type": "custom", "roles": ["owner"]}, + ) + assert sneaky.status_code == 400 and sneaky.json()["error"] == "invalid_roles" + + +# --- console pages ----------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_list_page_shows_users_and_the_nav_item(db: FakeAdminUsersDB) -> None: + redis = FakeRedis() + app = _app(db, redis) + db.add("reader", ["viewer"]) + owner = await _session(app, db, redis, 1) + + resp = await owner.get("/dashboard/admin-users") + + assert resp.status_code == 200 + assert "

    Console Users

    " in resp.text + assert 'href="/dashboard/admin-users"' in resp.text # the sidebar item + assert 'data-admin-user="reader"' in resp.text and "Read-only" in resp.text + + +@pytest.mark.asyncio +async def test_the_form_creates_a_user_and_reveals_the_password_once(db: FakeAdminUsersDB) -> None: + redis = FakeRedis() + app = _app(db, redis) + owner = await _session(app, db, redis, 1) + + form = await owner.get("/dashboard/admin-users/new") + assert ( + form.status_code == 200 + and 'name="user_type"' in form.text + and 'value="custom"' in form.text + ) + + resp = await owner.send( + "POST", + "/dashboard/admin-users/create", + htmx=True, + data={"username": "rita", "user_type": "custom", "roles": ["approval_reviewer", "auditor"]}, + ) + assert resp.status_code == 200 + assert "shown" in resp.text and "once" in resp.text and "js-copy-secret" in resp.text + assert resp.headers["cache-control"] == "no-store" + new = next(r for r in db.rows.values() if r["username"] == "rita") + assert new["roles"] == ["approval_reviewer", "auditor"] and new["must_change_password"] + + +@pytest.mark.asyncio +async def test_the_form_shows_errors_in_place(db: FakeAdminUsersDB) -> None: + redis = FakeRedis() + app = _app(db, redis) + owner = await _session(app, db, redis, 1) + + resp = await owner.send( + "POST", + "/dashboard/admin-users/create", + htmx=True, + data={"username": "x", "user_type": "read_only"}, + ) + assert resp.status_code == 200 + assert 'class="form-error"' in resp.text and "3 to 64 characters" in resp.text + assert len(db.rows) == 1 + + +@pytest.mark.asyncio +async def test_detail_page_hides_self_destructive_actions(db: FakeAdminUsersDB) -> None: + redis = FakeRedis() + app = _app(db, redis) + owner = await _session(app, db, redis, 1) + other = db.add("bob", ["viewer"], enabled=False) + + mine = await owner.get("/dashboard/admin-users/1") + assert mine.status_code == 200 and "(you)" in mine.text + assert "/reset-password" not in mine.text and "/disable" not in mine.text + + theirs = await owner.get(f"/dashboard/admin-users/{other}") + assert "/enable" in theirs.text and "/delete" in theirs.text and "/disable" not in theirs.text + + missing = await owner.get("/dashboard/admin-users/999") + assert missing.status_code == 200 and "Console user" in missing.text + + +@pytest.mark.asyncio +async def test_a_refused_page_action_explains_itself(db: FakeAdminUsersDB) -> None: + redis = FakeRedis() + app = _app(db, redis) + owner = await _session(app, db, redis, 1) + + resp = await owner.send("POST", "/dashboard/admin-users/1/disable", htmx=True) + + assert resp.status_code == 200 + assert 'class="form-error"' in resp.text and "your own account" in resp.text + + +def test_sidebar_offers_console_users_only_to_administrators() -> None: + config = InterLockConfig() + config.admin.secret_key = SECRET + template = create_app(config=config).state.templates.env.get_template("base.html") + + assert 'href="/dashboard/admin-users"' in template.render( + is_console_admin=True, admin_username="a" + ) + assert 'href="/dashboard/admin-users"' not in template.render( + is_console_admin=False, admin_username="a" + ) diff --git a/tests/unit/test_admin_users_service.py b/tests/unit/test_admin_users_service.py new file mode 100644 index 0000000..96f6261 --- /dev/null +++ b/tests/unit/test_admin_users_service.py @@ -0,0 +1,240 @@ +"""Console users: types, validation, temporary passwords and the guards.""" + +from __future__ import annotations + +import pytest + +from interlock.admin import admin_users as users +from interlock.admin.auth import verify_password +from interlock.admin.routes.password import password_change_problem +from tests.unit.admin_users_fake import FakeAdminUsersDB + + +@pytest.fixture +def db() -> FakeAdminUsersDB: + store = FakeAdminUsersDB() + store.add("admin", ["owner"]) # id 1, the signed-in administrator in these tests + return store + + +# --- validation --------------------------------------------------------------- + + +@pytest.mark.parametrize("raw", ["Jane.Doe", " jane@example.com ", "ops-1", "a_b"]) +def test_usernames_are_lowercased(raw: str) -> None: + assert users.normalize_username(raw) == raw.strip().lower() + + +@pytest.mark.parametrize("raw", ["", "ab", "-jane", "jane doe", "jané", "x" * 65, "jane;drop"]) +def test_bad_usernames_are_refused(raw: str) -> None: + with pytest.raises(users.AdminUserError) as err: + users.normalize_username(raw) + assert err.value.code == "invalid_username" + + +@pytest.mark.parametrize( + ("user_type", "custom", "roles"), + [ + ("administrator", [], ["owner"]), + ("read_write", [], ["security_admin"]), + ("read_only", [], ["viewer"]), + ("custom", ["auditor", "approval_reviewer"], ["approval_reviewer", "auditor"]), + ("read_only", ["owner"], ["viewer"]), + ], +) +def test_user_types_map_to_roles(user_type: str, custom: list[str], roles: list[str]) -> None: + assert users.resolve_roles(user_type, custom) == roles + + +@pytest.mark.parametrize( + ("user_type", "custom", "code"), + [ + ("custom", [], "no_roles"), + ("custom", ["owner"], "invalid_roles"), + ("custom", ["admin"], "invalid_roles"), + ("custom", ["superuser"], "invalid_roles"), + ("root", [], "invalid_roles"), + ], +) +def test_custom_roles_cannot_reach_administrator( + user_type: str, custom: list[str], code: str +) -> None: + with pytest.raises(users.AdminUserError) as err: + users.resolve_roles(user_type, custom) + assert err.value.code == code + + +def test_temporary_passwords_meet_the_policy_and_differ() -> None: + seen = set() + for _ in range(200): + password = users.generate_temporary_password() + assert ( + password_change_problem(username="u", current="", new=password, confirm=password) + is None + ) + assert len(password) == 23 and password.count("-") == 3 + assert not set(password) & set("0O1lI") + seen.add(password) + assert len(seen) == 200 + + +# --- create ------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_gives_a_temporary_password_that_must_be_changed(db: FakeAdminUsersDB) -> None: + user, password = await users.create_admin_user(db, username="Jane", user_type="read_only") + + assert user.username == "jane" and user.roles == ("viewer",) and user.enabled + assert user.must_change_password and user.password_set + stored = db.rows[user.id]["password_hash"] + assert password and verify_password(password, stored) + assert password not in str(user.to_public()) + assert "password_hash" not in user.to_public() + assert db.locks == 1 + + +@pytest.mark.asyncio +async def test_usernames_are_unique_regardless_of_case(db: FakeAdminUsersDB) -> None: + db.add("Legacy.User", ["viewer"]) + with pytest.raises(users.AdminUserError) as err: + await users.create_admin_user(db, username="legacy.user", user_type="read_only") + assert err.value.code == "duplicate_username" and err.value.status == 409 + + +@pytest.mark.asyncio +async def test_with_sso_on_non_administrators_need_a_subject_and_get_no_password( + db: FakeAdminUsersDB, +) -> None: + with pytest.raises(users.AdminUserError) as err: + await users.create_admin_user(db, username="sam", user_type="read_only", sso_enabled=True) + assert err.value.code == "sso_subject_required" + + user, password = await users.create_admin_user( + db, username="sam", user_type="read_only", sso_subject="00u1abc", sso_enabled=True + ) + assert password is None and not user.password_set and not user.must_change_password + + admin, admin_password = await users.create_admin_user( + db, username="breakglass", user_type="administrator", sso_enabled=True + ) + assert admin_password and admin.must_change_password + + +@pytest.mark.asyncio +async def test_an_sso_subject_belongs_to_one_console_user(db: FakeAdminUsersDB) -> None: + db.add("first", ["viewer"], oidc_subject="00u1") + with pytest.raises(users.AdminUserError) as err: + await users.create_admin_user( + db, username="second", user_type="read_only", sso_subject="00u1" + ) + assert err.value.code == "sso_subject_taken" + + +# --- guards ------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_an_administrator_cannot_change_their_own_account_here(db: FakeAdminUsersDB) -> None: + db.add("other", ["owner"]) + for call in ( + users.update_admin_user(db, actor_id=1, user_id=1, user_type="read_only"), + users.set_enabled(db, actor_id=1, user_id=1, enabled=False), + users.reset_password(db, actor_id=1, user_id=1), + users.delete_admin_user(db, actor_id=1, user_id=1), + ): + with pytest.raises(users.AdminUserError) as err: + await call + assert err.value.code == "self_protected" + assert db.rows[1]["roles"] == ["owner"] and db.rows[1]["enabled"] + + +@pytest.mark.asyncio +async def test_saving_your_own_email_is_allowed(db: FakeAdminUsersDB) -> None: + _, after = await users.update_admin_user( + db, actor_id=1, user_id=1, user_type="administrator", email="me@example.com" + ) + assert after.email == "me@example.com" and after.roles == ("owner",) + + +@pytest.mark.asyncio +async def test_the_last_enabled_administrator_cannot_be_removed(db: FakeAdminUsersDB) -> None: + # No acting session id here, so the self guard cannot mask this one. + with pytest.raises(users.AdminUserError) as err: + await users.set_enabled(db, actor_id=None, user_id=1, enabled=False) + assert err.value.code == "last_administrator" + with pytest.raises(users.AdminUserError) as err: + await users.update_admin_user(db, actor_id=None, user_id=1, user_type="read_write") + assert err.value.code == "last_administrator" + + other = db.add("second", ["Owner"]) # mixed-case legacy role still counts + await users.set_enabled(db, actor_id=other, user_id=1, enabled=False) + assert not db.rows[1]["enabled"] + + +@pytest.mark.asyncio +async def test_a_disabled_administrator_does_not_count(db: FakeAdminUsersDB) -> None: + db.add("dormant", ["owner"], enabled=False) + with pytest.raises(users.AdminUserError) as err: + await users.update_admin_user( + db, actor_id=None, user_id=1, user_type="custom", custom_roles=["auditor"] + ) + assert err.value.code == "last_administrator" + + +@pytest.mark.asyncio +async def test_delete_only_after_disable(db: FakeAdminUsersDB) -> None: + user, _ = await users.create_admin_user(db, username="temp", user_type="read_only") + with pytest.raises(users.AdminUserError) as err: + await users.delete_admin_user(db, actor_id=1, user_id=user.id) + assert err.value.code == "not_disabled" and err.value.status == 409 + + await users.set_enabled(db, actor_id=1, user_id=user.id, enabled=False) + gone = await users.delete_admin_user(db, actor_id=1, user_id=user.id) + assert gone.username == "temp" and user.id not in db.rows + + +@pytest.mark.asyncio +async def test_reset_password_forces_a_change_and_replaces_the_hash(db: FakeAdminUsersDB) -> None: + target = db.add("jane", ["viewer"], password_changed_at="earlier") + old = db.rows[target]["password_hash"] + user, password = await users.reset_password(db, actor_id=1, user_id=target) + + assert user.must_change_password and user.password_changed_at is None + assert db.rows[target]["password_hash"] != old + assert verify_password(password, db.rows[target]["password_hash"]) + + +@pytest.mark.asyncio +async def test_unknown_user_is_not_found(db: FakeAdminUsersDB) -> None: + with pytest.raises(users.AdminUserError) as err: + await users.get_admin_user(db, 999) + assert err.value.status == 404 + + +def test_audit_summary_never_carries_secrets() -> None: + user = users.AdminUser( + id=7, + username="jane", + roles=("viewer",), + enabled=True, + email="jane@example.com", + sso_subject="00u1", + created_at=None, + updated_at=None, + last_login_at=None, + last_login_ip=None, + must_change_password=True, + password_changed_at=None, + password_set=True, + ) + summary = user.audit_summary() + assert summary["sso_subject_configured"] is True + assert "00u1" not in str(summary) + assert not {key for key in summary if "password" in key or "hash" in key} + + +def test_console_user_changes_are_audited_strictly() -> None: + from interlock.admin.audit import _STRICT_MUTATION_PREFIXES + + assert "admin_user." in _STRICT_MUTATION_PREFIXES diff --git a/tests/unit/test_admin_ux_routes.py b/tests/unit/test_admin_ux_routes.py index 399f898..84b0abf 100644 --- a/tests/unit/test_admin_ux_routes.py +++ b/tests/unit/test_admin_ux_routes.py @@ -76,6 +76,7 @@ async def client(app): ("/dashboard/access-control/identities/new", "New Identity"), ("/dashboard/policies/new", "New Policy"), ("/dashboard/alerts/new", "New Alert Rule"), + ("/dashboard/admin-users/new", "New Console User"), ], ) async def test_direct_form_routes_render_full_shell(client, path, heading): @@ -94,6 +95,7 @@ async def test_direct_form_routes_render_full_shell(client, path, heading): ("/dashboard/access-control/identities/new", 'id="new-identity-form"'), ("/dashboard/policies/new", 'id="new-policy-form"'), ("/dashboard/alerts/new", 'id="alert-form"'), + ("/dashboard/admin-users/new", 'id="admin-user-form-card"'), ], ) async def test_inner_htmx_form_routes_render_fragments(client, path, marker): diff --git a/tools/audit/mutate.py b/tools/audit/mutate.py index c7d1c2d..abd4be6 100644 --- a/tools/audit/mutate.py +++ b/tools/audit/mutate.py @@ -333,6 +333,42 @@ class Result: tests=("tests/unit/test_admin_forced_password_change.py",), scope="unit", ), + Mutation( + name="admin-user-last-administrator-guard-removed", + control="Console users: the last enabled administrator cannot be disabled or demoted", + path="src/interlock/admin/admin_users.py", + old=" if not others:", + new=" if False: # MUTATION: the console can be left with no administrator", + tests=("tests/unit/test_admin_users_service.py",), + scope="unit", + ), + Mutation( + name="admin-user-self-guard-removed", + control="Console users: an administrator cannot demote, disable, reset or delete themselves", + path="src/interlock/admin/admin_users.py", + old=" if actor_id is not None and actor_id == user_id:", + new=" if False: # MUTATION: an administrator can lock themselves out", + tests=("tests/unit/test_admin_users_service.py", "tests/unit/test_admin_users_routes.py"), + scope="unit", + ), + Mutation( + name="admin-users-rule-opened-to-security-admin", + control="Console users: only owner and admin can manage console users", + path="src/interlock/admin/auth_middleware.py", + old=' ("/api/admin-users", _ADMIN_ONLY),\n ("/api/admin-auth/oidc/admins"', + new=' ("/api/admin-users", frozenset({"security_admin"})), # MUTATION\n ("/api/admin-auth/oidc/admins"', + tests=("tests/unit/test_admin_users_routes.py",), + scope="unit", + ), + Mutation( + name="admin-user-custom-roles-unchecked", + control="Console users: a custom user cannot be given owner or admin", + path="src/interlock/admin/admin_users.py", + old=" unknown = chosen - set(CUSTOM_ROLES)", + new=" unknown: set[str] = set() # MUTATION: any role, including owner", + tests=("tests/unit/test_admin_users_service.py", "tests/unit/test_admin_users_routes.py"), + scope="unit", + ), Mutation( name="viewer-added-to-mutation-fallback", control="Admin auth: a read-only console user can change nothing",