mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
Restores `agents-access` (**Coder Agents User**) as a built-in organization role and adds it to every organization's `default_org_member_roles`. Members keep Coder Agents by default, and admins can now restrict it per organization by removing the role from the defaults and granting it through IdP role sync or direct assignment. Since #28186, chat permissions lived in the organization-member floor, so the only way to limit Coder Agents was narrowing per-model access lists, which did not hide the feature. - Migration 000602 appends `agents-access` to every organization's defaults, so upgrading keeps every member's access. New organizations get it from `rbac.DefaultOrgMemberRoles()`. - Service accounts do not inherit the role from the defaults (`GetAuthorizationUserRoles`), so they still need an explicit grant for chat. - Owners and Organization Admins keep chat access regardless of defaults. Chats shared with a user stay readable without the role. - Role sync is unchanged. It does not store default roles, so after the default is removed a mapped user gets `agents-access` at their next sign-in. - The retired-role mechanism (`IsRetiredRoleName`) is removed. - `PATCH /api/v2/organizations/{organization}` rejects duplicate `default_org_member_roles` entries (the CLI already de-duplicated them). - Custom organization role requests treat built-in role names case-insensitively, since custom role names are stored lowercase and `Agents-Access` would otherwise become an undeletable shadow of the built-in role. - The Agents permission alert tells restricted members their chats are kept, and the model selector for users without chat permission no longer claims that no models are configured. Docs add "Control who can use Coder Agents" to the Agents getting-started guide. **NOTE**: Migration 000602 deletes custom roles named `agents-access`, which the built-in role would shadow. The down migration does not restore them. Stored member grants of `agents-access` from the earlier built-in role or a deleted custom role stay and now grant chat access. ## Validation - Remote dogfood [UAT](https://dogfood.cdr.dev/agents/e1c18dc6-f6f0-49a1-b6cd-6ce014b51a86) passed onfb441dfae5, covering case variants of built-in role names on create, update, delete, default roles, and member grants. The previous round, ona6fef66b3f, passed the upgrade from main5dde49dffbon a fresh database.6b7f107e0bonly changes wording (docs, comments, UI copy, CLI help) and did not go through UAT. - Local: rbac, dbauthz, idpsync, migrations (chain including 000602 down and up), querier, httpmw, provisionerdserver, coderd and enterprise chat, role, and organization tests, chatd, toolsdk, CLI; frontend vitest, story tests, biome, oxlint, tsc. ## Known issues (not changed here) - Sending from a chat page that was already open when access was removed shows a generic 404 panel until reload. - The members page role editor is not locked for OIDC users under org role sync; the API rejects the change with 400. - Creating a custom role named after a system role (`organization-member`, `organization-service-account`) returns 400 with a raw database unique-constraint error. <details> <summary>Review record</summary> - Remote UAT (UAT Critic sub-agent `6636c855ec`): round 1 on8bec029d35passed with issues, fixed ind7ba937a10and e0f16e1a45f; round 2 ona6fef66b3fpassed; round 3 onfb441dfae5passed. - Codex: round 1 ona6fef66b3ffound one P2 (case variants of built-in role names), fixed infb441dfae5with a red-green test. Rounds 2 and 3 onfb441dfae5found no major issues. - mafredri onfb441dfae5(PR text only): nine wording suggestions applied in 6b7f107e0b9; NOTE extended for stored grants; description trimmed. - Frontend: FE1 to FE10 and coder-frontend-review checkpoints self-audited. </details> > 🤖 This PR was opened by [Xum](https://mux.coder.com) on behalf of @ibetitsmike.
764 lines
19 KiB
SQL
764 lines
19 KiB
SQL
-- name: UpdateUserLoginType :one
|
|
UPDATE
|
|
users
|
|
SET
|
|
login_type = @new_login_type,
|
|
hashed_password = CASE WHEN @new_login_type = 'password' :: login_type THEN
|
|
users.hashed_password
|
|
ELSE
|
|
-- If the login type is not password, then the password should be
|
|
-- cleared.
|
|
'':: bytea
|
|
END
|
|
WHERE
|
|
id = @user_id
|
|
AND NOT is_system
|
|
RETURNING *;
|
|
|
|
-- name: GetUserByID :one
|
|
SELECT
|
|
*
|
|
FROM
|
|
users
|
|
WHERE
|
|
id = $1
|
|
LIMIT
|
|
1;
|
|
|
|
-- name: ValidateUserIDs :one
|
|
WITH input AS (
|
|
SELECT
|
|
unnest(@user_ids::uuid[]) AS id
|
|
)
|
|
SELECT
|
|
array_agg(input.id)::uuid[] as invalid_user_ids,
|
|
COUNT(*) = 0 as ok
|
|
FROM
|
|
-- Preserve rows where there is not a matching left (users) row for each
|
|
-- right (input) row...
|
|
users
|
|
RIGHT JOIN input ON users.id = input.id
|
|
WHERE
|
|
-- ...so that we can retain exactly those rows where an input ID does not
|
|
-- match an existing user...
|
|
users.id IS NULL OR
|
|
-- ...or that only matches a user that was deleted.
|
|
users.deleted = true;
|
|
|
|
-- name: GetUsersByIDs :many
|
|
-- This shouldn't check for deleted, because it's frequently used
|
|
-- to look up references to actions. eg. a user could build a workspace
|
|
-- for another user, then be deleted... we still want them to appear!
|
|
SELECT * FROM users WHERE id = ANY(@ids :: uuid [ ]);
|
|
|
|
-- name: GetUserByEmailOrUsername :one
|
|
SELECT
|
|
*
|
|
FROM
|
|
users
|
|
WHERE
|
|
(LOWER(username) = LOWER(@username) OR (@email != '' AND LOWER(email) = LOWER(@email))) AND
|
|
deleted = false
|
|
LIMIT
|
|
1;
|
|
|
|
-- name: GetUserCount :one
|
|
SELECT
|
|
COUNT(*)
|
|
FROM
|
|
users
|
|
WHERE
|
|
deleted = false
|
|
AND CASE WHEN @include_system::bool THEN TRUE ELSE is_system = false END;
|
|
|
|
-- name: GetActiveUserCount :one
|
|
SELECT
|
|
COUNT(*)
|
|
FROM
|
|
users
|
|
WHERE
|
|
status = 'active'::user_status AND deleted = false
|
|
AND is_service_account = false
|
|
AND CASE WHEN @include_system::bool THEN TRUE ELSE is_system = false END;
|
|
|
|
-- name: InsertUser :one
|
|
INSERT INTO
|
|
users (
|
|
id,
|
|
email,
|
|
username,
|
|
name,
|
|
hashed_password,
|
|
created_at,
|
|
updated_at,
|
|
rbac_roles,
|
|
login_type,
|
|
status,
|
|
is_service_account
|
|
)
|
|
VALUES
|
|
($1, $2, $3, $4, $5, $6, $7, $8, $9,
|
|
-- if the status passed in is empty, fallback to dormant, which is what
|
|
-- we were doing before.
|
|
COALESCE(NULLIF(@status::text, '')::user_status, 'dormant'::user_status),
|
|
@is_service_account::bool
|
|
) RETURNING *;
|
|
|
|
-- name: UpdateUserProfile :one
|
|
UPDATE
|
|
users
|
|
SET
|
|
email = $2,
|
|
username = $3,
|
|
avatar_url = $4,
|
|
updated_at = $5,
|
|
name = $6
|
|
WHERE
|
|
id = $1
|
|
RETURNING *;
|
|
|
|
-- name: UpdateUserGithubComUserID :exec
|
|
UPDATE
|
|
users
|
|
SET
|
|
github_com_user_id = $2
|
|
WHERE
|
|
id = $1;
|
|
|
|
-- name: GetUserAppearanceSettings :one
|
|
SELECT
|
|
COALESCE(MAX(value) FILTER (WHERE key = 'theme_preference'), '')::text AS theme_preference,
|
|
COALESCE(MAX(value) FILTER (WHERE key = 'theme_mode'), '')::text AS theme_mode,
|
|
COALESCE(MAX(value) FILTER (WHERE key = 'theme_light'), '')::text AS theme_light,
|
|
COALESCE(MAX(value) FILTER (WHERE key = 'theme_dark'), '')::text AS theme_dark,
|
|
COALESCE(MAX(value) FILTER (WHERE key = 'terminal_font'), '')::text AS terminal_font
|
|
FROM
|
|
user_configs
|
|
WHERE
|
|
user_id = @user_id
|
|
AND key IN (
|
|
'theme_preference',
|
|
'theme_mode',
|
|
'theme_light',
|
|
'theme_dark',
|
|
'terminal_font'
|
|
);
|
|
|
|
-- name: UpdateUserThemePreference :one
|
|
INSERT INTO
|
|
user_configs (user_id, key, value)
|
|
VALUES
|
|
(@user_id, 'theme_preference', @theme_preference)
|
|
ON CONFLICT
|
|
ON CONSTRAINT user_configs_pkey
|
|
DO UPDATE
|
|
SET
|
|
value = @theme_preference
|
|
WHERE user_configs.user_id = @user_id
|
|
AND user_configs.key = 'theme_preference'
|
|
RETURNING *;
|
|
|
|
-- name: UpdateUserTerminalFont :one
|
|
INSERT INTO
|
|
user_configs (user_id, key, value)
|
|
VALUES
|
|
(@user_id, 'terminal_font', @terminal_font)
|
|
ON CONFLICT
|
|
ON CONSTRAINT user_configs_pkey
|
|
DO UPDATE
|
|
SET
|
|
value = @terminal_font
|
|
WHERE user_configs.user_id = @user_id
|
|
AND user_configs.key = 'terminal_font'
|
|
RETURNING *;
|
|
|
|
-- name: UpdateUserThemeMode :one
|
|
INSERT INTO
|
|
user_configs (user_id, key, value)
|
|
VALUES
|
|
(@user_id, 'theme_mode', @theme_mode)
|
|
ON CONFLICT
|
|
ON CONSTRAINT user_configs_pkey
|
|
DO UPDATE
|
|
SET
|
|
value = @theme_mode
|
|
WHERE user_configs.user_id = @user_id
|
|
AND user_configs.key = 'theme_mode'
|
|
RETURNING *;
|
|
|
|
-- name: UpdateUserThemeLight :one
|
|
INSERT INTO
|
|
user_configs (user_id, key, value)
|
|
VALUES
|
|
(@user_id, 'theme_light', @theme_light)
|
|
ON CONFLICT
|
|
ON CONSTRAINT user_configs_pkey
|
|
DO UPDATE
|
|
SET
|
|
value = @theme_light
|
|
WHERE user_configs.user_id = @user_id
|
|
AND user_configs.key = 'theme_light'
|
|
RETURNING *;
|
|
|
|
-- name: UpdateUserThemeDark :one
|
|
INSERT INTO
|
|
user_configs (user_id, key, value)
|
|
VALUES
|
|
(@user_id, 'theme_dark', @theme_dark)
|
|
ON CONFLICT
|
|
ON CONSTRAINT user_configs_pkey
|
|
DO UPDATE
|
|
SET
|
|
value = @theme_dark
|
|
WHERE user_configs.user_id = @user_id
|
|
AND user_configs.key = 'theme_dark'
|
|
RETURNING *;
|
|
|
|
-- name: GetUserChatCustomPrompt :one
|
|
SELECT
|
|
value as chat_custom_prompt
|
|
FROM
|
|
user_configs
|
|
WHERE
|
|
user_id = @user_id
|
|
AND key = 'chat_custom_prompt';
|
|
|
|
-- name: UpdateUserChatCustomPrompt :one
|
|
INSERT INTO
|
|
user_configs (user_id, key, value)
|
|
VALUES
|
|
(@user_id, 'chat_custom_prompt', @chat_custom_prompt)
|
|
ON CONFLICT
|
|
ON CONSTRAINT user_configs_pkey
|
|
DO UPDATE
|
|
SET
|
|
value = @chat_custom_prompt
|
|
WHERE user_configs.user_id = @user_id
|
|
AND user_configs.key = 'chat_custom_prompt'
|
|
RETURNING *;
|
|
|
|
-- name: ListUserChatCompactionThresholds :many
|
|
SELECT user_id, key, value FROM user_configs
|
|
WHERE user_id = @user_id
|
|
AND key LIKE 'chat\_compaction\_threshold\_pct:%'
|
|
ORDER BY key;
|
|
|
|
-- name: GetUserChatCompactionThreshold :one
|
|
SELECT value AS threshold_percent FROM user_configs
|
|
WHERE user_id = @user_id AND key = @key;
|
|
|
|
-- name: UpdateUserChatCompactionThreshold :one
|
|
INSERT INTO user_configs (user_id, key, value)
|
|
VALUES (@user_id, @key, (@threshold_percent::int)::text)
|
|
ON CONFLICT ON CONSTRAINT user_configs_pkey
|
|
DO UPDATE SET value = (@threshold_percent::int)::text
|
|
RETURNING *;
|
|
|
|
-- name: DeleteUserChatCompactionThreshold :exec
|
|
DELETE FROM user_configs WHERE user_id = @user_id AND key = @key;
|
|
|
|
-- name: GetUserChatDebugLoggingEnabled :one
|
|
SELECT
|
|
COALESCE((
|
|
SELECT value = 'true'
|
|
FROM user_configs
|
|
WHERE user_id = @user_id
|
|
AND key = 'chat_debug_logging_enabled'
|
|
), false) :: boolean AS debug_logging_enabled;
|
|
|
|
-- name: UpsertUserChatDebugLoggingEnabled :exec
|
|
INSERT INTO user_configs (user_id, key, value)
|
|
VALUES (
|
|
@user_id,
|
|
'chat_debug_logging_enabled',
|
|
CASE
|
|
WHEN sqlc.arg(debug_logging_enabled)::bool THEN 'true'
|
|
ELSE 'false'
|
|
END
|
|
)
|
|
ON CONFLICT ON CONSTRAINT user_configs_pkey
|
|
DO UPDATE SET value = CASE
|
|
WHEN sqlc.arg(debug_logging_enabled)::bool THEN 'true'
|
|
ELSE 'false'
|
|
END
|
|
WHERE user_configs.user_id = @user_id
|
|
AND user_configs.key = 'chat_debug_logging_enabled';
|
|
|
|
-- name: GetUserThinkingDisplayMode :one
|
|
SELECT
|
|
value AS thinking_display_mode
|
|
FROM
|
|
user_configs
|
|
WHERE
|
|
user_id = @user_id
|
|
AND key = 'preference_thinking_display_mode';
|
|
|
|
-- name: UpdateUserThinkingDisplayMode :one
|
|
INSERT INTO
|
|
user_configs (user_id, key, value)
|
|
VALUES
|
|
(@user_id, 'preference_thinking_display_mode', @thinking_display_mode::text)
|
|
ON CONFLICT
|
|
ON CONSTRAINT user_configs_pkey
|
|
DO UPDATE
|
|
SET
|
|
value = @thinking_display_mode
|
|
WHERE user_configs.user_id = @user_id
|
|
AND user_configs.key = 'preference_thinking_display_mode'
|
|
RETURNING value AS thinking_display_mode;
|
|
|
|
-- name: GetUserShellToolDisplayMode :one
|
|
SELECT
|
|
value AS shell_tool_display_mode
|
|
FROM
|
|
user_configs
|
|
WHERE
|
|
user_id = @user_id
|
|
AND key = 'preference_shell_tool_display_mode';
|
|
|
|
-- name: UpdateUserShellToolDisplayMode :one
|
|
INSERT INTO
|
|
user_configs (user_id, key, value)
|
|
VALUES
|
|
(@user_id, 'preference_shell_tool_display_mode', @shell_tool_display_mode::text)
|
|
ON CONFLICT
|
|
ON CONSTRAINT user_configs_pkey
|
|
DO UPDATE
|
|
SET
|
|
value = @shell_tool_display_mode
|
|
WHERE user_configs.user_id = @user_id
|
|
AND user_configs.key = 'preference_shell_tool_display_mode'
|
|
RETURNING value AS shell_tool_display_mode;
|
|
|
|
-- name: GetUserCodeDiffDisplayMode :one
|
|
SELECT
|
|
value AS code_diff_display_mode
|
|
FROM
|
|
user_configs
|
|
WHERE
|
|
user_id = @user_id
|
|
AND key = 'preference_code_diff_display_mode';
|
|
|
|
-- name: UpdateUserCodeDiffDisplayMode :one
|
|
INSERT INTO
|
|
user_configs (user_id, key, value)
|
|
VALUES
|
|
(@user_id, 'preference_code_diff_display_mode', @code_diff_display_mode::text)
|
|
ON CONFLICT
|
|
ON CONSTRAINT user_configs_pkey
|
|
DO UPDATE
|
|
SET
|
|
value = @code_diff_display_mode
|
|
WHERE user_configs.user_id = @user_id
|
|
AND user_configs.key = 'preference_code_diff_display_mode'
|
|
RETURNING value AS code_diff_display_mode;
|
|
|
|
-- name: GetUserAgentChatSendShortcut :one
|
|
SELECT
|
|
value AS agent_chat_send_shortcut
|
|
FROM
|
|
user_configs
|
|
WHERE
|
|
user_id = @user_id
|
|
AND key = 'preference_agent_chat_send_shortcut';
|
|
|
|
-- name: UpdateUserAgentChatSendShortcut :one
|
|
INSERT INTO
|
|
user_configs (user_id, key, value)
|
|
VALUES
|
|
(@user_id, 'preference_agent_chat_send_shortcut', @agent_chat_send_shortcut::text)
|
|
ON CONFLICT
|
|
ON CONSTRAINT user_configs_pkey
|
|
DO UPDATE
|
|
SET
|
|
value = @agent_chat_send_shortcut
|
|
WHERE user_configs.user_id = @user_id
|
|
AND user_configs.key = 'preference_agent_chat_send_shortcut'
|
|
RETURNING value AS agent_chat_send_shortcut;
|
|
|
|
-- name: GetUserCollapseAssistantSteps :one
|
|
SELECT
|
|
value::boolean as collapse_assistant_steps
|
|
FROM
|
|
user_configs
|
|
WHERE
|
|
user_id = @user_id
|
|
AND key = 'preference_collapse_assistant_steps';
|
|
|
|
-- name: UpdateUserCollapseAssistantSteps :one
|
|
INSERT INTO
|
|
user_configs (user_id, key, value)
|
|
VALUES
|
|
(@user_id, 'preference_collapse_assistant_steps', (@collapse_assistant_steps::boolean)::text)
|
|
ON CONFLICT
|
|
ON CONSTRAINT user_configs_pkey
|
|
DO UPDATE
|
|
SET
|
|
value = @collapse_assistant_steps
|
|
WHERE user_configs.user_id = @user_id
|
|
AND user_configs.key = 'preference_collapse_assistant_steps'
|
|
RETURNING value::boolean AS collapse_assistant_steps;
|
|
|
|
-- name: UpdateUserRoles :one
|
|
UPDATE
|
|
users
|
|
SET
|
|
-- Remove all duplicates from the roles.
|
|
rbac_roles = ARRAY(SELECT DISTINCT UNNEST(@granted_roles :: text[]))
|
|
WHERE
|
|
id = @id
|
|
RETURNING *;
|
|
|
|
-- name: UpdateUserHashedPassword :exec
|
|
UPDATE
|
|
users
|
|
SET
|
|
hashed_password = $2,
|
|
hashed_one_time_passcode = NULL,
|
|
one_time_passcode_expires_at = NULL
|
|
WHERE
|
|
id = $1;
|
|
|
|
-- name: UpdateUserEmail :one
|
|
UPDATE
|
|
users
|
|
SET
|
|
email = @new_email,
|
|
updated_at = @updated_at,
|
|
hashed_one_time_passcode = NULL,
|
|
one_time_passcode_expires_at = NULL
|
|
WHERE
|
|
LOWER(email) = LOWER(@old_email)
|
|
AND deleted = false
|
|
RETURNING *;
|
|
|
|
-- name: UpdateUserDeletedByID :exec
|
|
UPDATE
|
|
users
|
|
SET
|
|
deleted = true
|
|
WHERE
|
|
id = $1;
|
|
|
|
-- name: GetUsers :many
|
|
-- This will never return deleted users.
|
|
SELECT
|
|
*, COUNT(*) OVER() AS count
|
|
FROM
|
|
users
|
|
WHERE
|
|
users.deleted = false
|
|
AND CASE
|
|
-- This allows using the last element on a page as effectively a cursor.
|
|
-- This is an important option for scripts that need to paginate without
|
|
-- duplicating or missing data.
|
|
WHEN @after_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN (
|
|
-- The pagination cursor is the last ID of the previous page.
|
|
-- The query is ordered by the username field, so select all
|
|
-- rows after the cursor.
|
|
(LOWER(username)) > (
|
|
SELECT
|
|
LOWER(username)
|
|
FROM
|
|
users
|
|
WHERE
|
|
id = @after_id
|
|
)
|
|
)
|
|
ELSE true
|
|
END
|
|
-- Start filters
|
|
-- Filter by email, username, or name (display name)
|
|
AND CASE
|
|
WHEN @search :: text != '' THEN (
|
|
email ILIKE concat('%', @search, '%')
|
|
OR username ILIKE concat('%', @search, '%')
|
|
OR name ILIKE concat('%', @search, '%')
|
|
)
|
|
ELSE true
|
|
END
|
|
-- Filter by name (display name)
|
|
AND CASE
|
|
WHEN @name :: text != '' THEN
|
|
name ILIKE concat('%', @name, '%')
|
|
ELSE true
|
|
END
|
|
-- Filter by exact username
|
|
AND CASE
|
|
WHEN @exact_username :: text != '' THEN
|
|
lower(username) = lower(@exact_username)
|
|
ELSE true
|
|
END
|
|
-- Filter by exact email
|
|
AND CASE
|
|
WHEN @exact_email :: text != '' THEN
|
|
lower(email) = lower(@exact_email)
|
|
ELSE true
|
|
END
|
|
-- Filter by status
|
|
AND CASE
|
|
-- @status needs to be a text because it can be empty, If it was
|
|
-- user_status enum, it would not.
|
|
WHEN cardinality(@status :: user_status[]) > 0 THEN
|
|
status = ANY(@status :: user_status[])
|
|
ELSE true
|
|
END
|
|
-- Filter by rbac_roles
|
|
AND CASE
|
|
-- @rbac_role allows filtering by rbac roles. If 'member' is included, show everyone, as
|
|
-- everyone is a member.
|
|
WHEN cardinality(@rbac_role :: text[]) > 0 AND 'member' != ANY(@rbac_role :: text[]) THEN
|
|
rbac_roles && @rbac_role :: text[]
|
|
ELSE true
|
|
END
|
|
-- Filter by last_seen
|
|
AND CASE
|
|
WHEN @last_seen_before :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
|
|
last_seen_at <= @last_seen_before
|
|
ELSE true
|
|
END
|
|
AND CASE
|
|
WHEN @last_seen_after :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
|
|
last_seen_at >= @last_seen_after
|
|
ELSE true
|
|
END
|
|
-- Filter by created_at
|
|
AND CASE
|
|
WHEN @created_before :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
|
|
created_at <= @created_before
|
|
ELSE true
|
|
END
|
|
AND CASE
|
|
WHEN @created_after :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
|
|
created_at >= @created_after
|
|
ELSE true
|
|
END
|
|
-- Filter by system type
|
|
AND CASE
|
|
WHEN @include_system::bool THEN TRUE
|
|
ELSE is_system = false
|
|
END
|
|
-- Filter by github.com user ID
|
|
AND CASE
|
|
WHEN @github_com_user_id :: bigint != 0 THEN
|
|
github_com_user_id = @github_com_user_id
|
|
ELSE true
|
|
END
|
|
-- Filter by login_type
|
|
AND CASE
|
|
WHEN cardinality(@login_type :: login_type[]) > 0 THEN
|
|
login_type = ANY(@login_type :: login_type[])
|
|
ELSE true
|
|
END
|
|
-- Filter by service account.
|
|
AND CASE
|
|
WHEN sqlc.narg('is_service_account') :: boolean IS NOT NULL THEN
|
|
is_service_account = sqlc.narg('is_service_account') :: boolean
|
|
ELSE true
|
|
END
|
|
-- End of filters
|
|
|
|
-- Authorize Filter clause will be injected below in GetAuthorizedUsers
|
|
-- @authorize_filter
|
|
ORDER BY
|
|
-- Deterministic and consistent ordering of all users. This is to ensure consistent pagination.
|
|
LOWER(username) ASC OFFSET @offset_opt
|
|
LIMIT
|
|
-- A null limit means "no limit", so 0 means return all
|
|
NULLIF(@limit_opt :: int, 0);
|
|
|
|
-- name: UpdateUserStatus :one
|
|
UPDATE
|
|
users
|
|
SET
|
|
status = $2,
|
|
updated_at = $3,
|
|
-- If the user is logging in, set last_seen_at to updated_at.
|
|
last_seen_at = CASE WHEN @user_is_seen :: boolean THEN $3 :: timestamptz ELSE last_seen_at END
|
|
WHERE
|
|
id = $1 RETURNING *;
|
|
|
|
-- name: UpdateUserLastSeenAt :one
|
|
UPDATE
|
|
users
|
|
SET
|
|
last_seen_at = $2,
|
|
updated_at = $3
|
|
WHERE
|
|
id = $1 RETURNING *;
|
|
|
|
|
|
-- name: GetAuthorizationUserRoles :one
|
|
-- This function returns roles for authorization purposes. Implied member roles
|
|
-- are included.
|
|
-- Must stay semantically in sync with GetActiveUsersAuthorizationRoles
|
|
-- (implied member roles, org default roles, groups);
|
|
-- TestGetActiveUsersAuthorizationRolesParity enforces this.
|
|
SELECT
|
|
-- username and email are returned just to help for logging purposes
|
|
-- status is used to enforce 'suspended' users, as all roles are ignored
|
|
-- when suspended.
|
|
id, username, status, email,
|
|
-- All user roles, including their org roles.
|
|
array_cat(
|
|
-- All users are members
|
|
array_append(users.rbac_roles, 'member'),
|
|
(
|
|
SELECT
|
|
-- The roles are returned as a flat array, org scoped and site side.
|
|
-- Concatenating the organization id scopes the organization roles.
|
|
array_agg(org_roles || ':' || organization_members.organization_id::text)
|
|
FROM
|
|
organization_members
|
|
JOIN organizations ON organizations.id = organization_members.organization_id,
|
|
-- All org members get an implied role for their orgs. Most members
|
|
-- get organization-member, but service accounts will get
|
|
-- organization-service-account instead. They're largely the same,
|
|
-- but having them be distinct means we can allow configuring
|
|
-- service-accounts to have slightly broader permissions, such as
|
|
-- for workspace sharing.
|
|
--
|
|
-- organizations.default_org_member_roles is unioned in so changes
|
|
-- to org defaults propagate to every member on the next request.
|
|
-- Service accounts do not inherit agents-access from the defaults
|
|
-- so they only get chat access through an explicit grant.
|
|
unnest(
|
|
array_cat(
|
|
array_append(
|
|
roles,
|
|
CASE WHEN users.is_service_account THEN
|
|
'organization-service-account'
|
|
ELSE
|
|
'organization-member'
|
|
END
|
|
),
|
|
CASE WHEN users.is_service_account THEN
|
|
array_remove(organizations.default_org_member_roles, 'agents-access')
|
|
ELSE
|
|
organizations.default_org_member_roles
|
|
END
|
|
)
|
|
) AS org_roles
|
|
WHERE
|
|
user_id = users.id
|
|
)
|
|
) :: text[] AS roles,
|
|
-- All groups the user is in.
|
|
(
|
|
SELECT
|
|
array_agg(
|
|
group_members.group_id :: text
|
|
)
|
|
FROM
|
|
group_members
|
|
WHERE
|
|
user_id = users.id
|
|
) :: text[] AS groups
|
|
FROM
|
|
users
|
|
WHERE
|
|
users.id = @user_id;
|
|
|
|
-- name: GetActiveUsersAuthorizationRoles :many
|
|
-- Returns the authorization roles (site and org-scoped, including implied
|
|
-- member roles and organization default roles) and the group memberships
|
|
-- for every active, non-deleted user who is neither a system user nor a
|
|
-- service account, matching the GetActiveUserCount population.
|
|
-- Must stay semantically in sync with GetAuthorizationUserRoles;
|
|
-- TestGetActiveUsersAuthorizationRolesParity enforces this.
|
|
WITH org_roles AS (
|
|
SELECT
|
|
organization_members.user_id,
|
|
-- The roles are returned as a flat array, org scoped and site side.
|
|
-- Concatenating the organization id scopes the organization roles.
|
|
array_agg(org_role || ':' || organization_members.organization_id::text) AS roles
|
|
FROM
|
|
organization_members
|
|
JOIN organizations ON organizations.id = organization_members.organization_id,
|
|
-- All org members get an implied organization-member role for
|
|
-- their orgs. Memberships of service accounts are aggregated here
|
|
-- too, but their rows never survive the join against the outer
|
|
-- WHERE, so the organization-service-account case does not apply.
|
|
--
|
|
-- organizations.default_org_member_roles applies to every member
|
|
-- but is not materialized on membership rows, so it is unioned in
|
|
-- here.
|
|
unnest(
|
|
array_cat(
|
|
array_append(organization_members.roles, 'organization-member'),
|
|
organizations.default_org_member_roles
|
|
)
|
|
) AS org_role
|
|
GROUP BY
|
|
organization_members.user_id
|
|
),
|
|
user_groups AS (
|
|
SELECT
|
|
group_members.user_id,
|
|
array_agg(group_members.group_id :: text) AS groups
|
|
FROM
|
|
group_members
|
|
GROUP BY
|
|
group_members.user_id
|
|
)
|
|
SELECT
|
|
users.id,
|
|
array_cat(
|
|
-- All users are members
|
|
array_append(users.rbac_roles, 'member'),
|
|
-- Users with no org memberships have no org_roles row.
|
|
coalesce(org_roles.roles, ARRAY[]::text[])
|
|
) :: text[] AS roles,
|
|
coalesce(user_groups.groups, ARRAY[]::text[]) :: text[] AS groups
|
|
FROM
|
|
users
|
|
LEFT JOIN org_roles ON org_roles.user_id = users.id
|
|
LEFT JOIN user_groups ON user_groups.user_id = users.id
|
|
WHERE
|
|
users.status = 'active'::user_status
|
|
AND users.deleted = false
|
|
AND users.is_system = false
|
|
AND users.is_service_account = false;
|
|
|
|
-- name: UpdateUserQuietHoursSchedule :one
|
|
UPDATE
|
|
users
|
|
SET
|
|
quiet_hours_schedule = $2
|
|
WHERE
|
|
id = $1
|
|
RETURNING *;
|
|
|
|
|
|
-- name: UpdateInactiveUsersToDormant :many
|
|
UPDATE
|
|
users
|
|
SET
|
|
status = 'dormant'::user_status,
|
|
updated_at = @updated_at
|
|
WHERE
|
|
last_seen_at < @last_seen_after :: timestamp
|
|
AND status = 'active'::user_status
|
|
AND NOT is_system
|
|
RETURNING id, email, username, last_seen_at;
|
|
|
|
-- AllUserIDs returns all UserIDs regardless of user status or deletion.
|
|
-- name: AllUserIDs :many
|
|
SELECT DISTINCT id FROM USERS
|
|
WHERE CASE WHEN @include_system::bool THEN TRUE ELSE is_system = false END;
|
|
|
|
-- name: UpdateUserHashedOneTimePasscode :exec
|
|
UPDATE
|
|
users
|
|
SET
|
|
hashed_one_time_passcode = $2,
|
|
one_time_passcode_expires_at = $3
|
|
WHERE
|
|
id = $1
|
|
;
|
|
|
|
-- name: GetUserForChatSyntheticAPIKeyByID :one
|
|
SELECT *
|
|
FROM users
|
|
WHERE id = @id::uuid;
|