Files
coder/coderd/database/queries/users.sql
T
Michael Suchacz 6826da9397 feat: restore agents-access as a default organization role (#30004)
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 on fb441dfae5, covering case variants of built-in role names on
create, update, delete, default roles, and member grants. The previous
round, on a6fef66b3f, passed the upgrade from main 5dde49dffb on a
fresh database. 6b7f107e0b only 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 on 8bec029d35
passed with issues, fixed in d7ba937a10 and e0f16e1a45f; round 2 on
a6fef66b3f passed; round 3 on fb441dfae5 passed.
- Codex: round 1 on a6fef66b3f found one P2 (case variants of built-in
role names), fixed in fb441dfae5 with a red-green test. Rounds 2 and 3
on fb441dfae5 found no major issues.
- mafredri on fb441dfae5 (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.
2026-09-27 23:34:26 +02:00

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;