mirror of
https://github.com/coder/coder.git
synced 2026-09-22 21:22:17 +08:00
Adds permission-based license seat counting behind the
`workspace-capable-licensing` experiment. When the experiment is enabled
and a valid license carries the AI Governance add-on, the `user_limit`
feature counts only active users the RBAC engine authorizes to create a
workspace, instead of every active user. Users without workspace-create
capability ("gateway accounts", e.g. AI-Gateway-only users) no longer
consume seats.
## How it works
- A new `GetActiveUsersAuthorizationRoles` bulk query returns effective
roles (implied member roles, org default member roles) and group
memberships for every seat-eligible user (active, not deleted, not
system, not a service account), matching `GetActiveUserCount` semantics.
- `license.CountWorkspaceCapableUsers` evaluates `workspace.create`
against the any-organization object form, which covers site-wide grants,
membership grants, and org-scoped bans in one check. Evaluation is
deduplicated on a sha256 of each user's canonical subject JSON (a fixed
sentinel user ID, sorted deduplicated roles and groups), so cost scales
with unique subjects rather than user count, and every subject field
participates in both the evaluation and the key.
- The AI Governance add-on is only known after license claims are
parsed, so `Entitlements()` passes a lazy `WorkspaceCapableUserCountFn`
(following the `ManagedAgentCountFn` precedent) and
`LicensesEntitlements` resolves it when a validated add-on is present.
Each license's `user_limit` claim becomes a candidate pair of limit and
counting mode, the most favorable pair is selected (see Behavior notes),
and the selected pair's limit, entitlement, and count become the
`user_limit` feature's terms; the warnings read the same values.
`license.Entitlements` gains `logger`, `authorizer`, and `experiments`
parameters.
- All custom roles are prefetched in a single query before evaluation
(new exported `rolestore.PrefetchCustomRoles`), and each count emits one
Info log line (capable count, eligible active users, unique subjects,
elapsed) whose presence identifies the counting mode. The count is
bounded by a 60s timeout.
## Behavior notes
- Without the experiment or without the add-on, the legacy
`GetActiveUserCount` path is unchanged.
- When the mode is active, the over-limit and expired-limit warnings say
"workspace-capable users" instead of "active users", since that is what
was counted.
- With multiple licenses, each license's `user_limit` claim forms a
candidate pair of limit and counting mode (workspace-capable for add-on
licenses, all active users otherwise), and the most favorable pair is
enforced: a pair satisfied by its own count wins over any unsatisfied
one, then higher entitlement, then higher limit. One license's limit is
never combined with another license's counting mode, so a small add-on
license can neither borrow a bigger non-add-on limit nor suppress it.
- Licenses in their grace period still gate the count; it reverts to the
legacy count only on hard expiry. While the add-on exists only on
grace-period licenses, a warning tells admins the counting mode will
revert and states the legacy active-user count they will then be
measured by.
- Count errors (database failures, timeout) abort the entitlements
computation, matching the legacy count's error semantics: the refresh
fails and the caller keeps the previous entitlements rather than a
silently different count. One exception: a stored role string that fails
to parse is logged and treated as not workspace-capable instead of
failing the refresh, since authorization fails closed on such roles
anyway.
- The experiment is deliberately not in `ExperimentsSafe`.
Part of the gateway-accounts feature; no behavior changes for
deployments without the experiment.
## Stack
Part 1 of the gateway-accounts stack. Each PR builds on the previous:
1. **#27279 (this PR)**: permission-based license seat counting. Behind
the `workspace-capable-licensing` experiment and gated on the AI
Governance add-on, `user_limit` counts only users the RBAC engine
authorizes to create workspaces.
2. **#27280**: adds the `organization-ai-gateway-access` org role
carrying the AI Bridge interception permissions (extracted from the
member floors, backfilled into org default roles by migration) and
enforces it at AI Gateway authentication; bridge usage stops claiming AI
Governance seats under the experiment.
3. ~~**#27281**: gates workspace ACL grants on matching member-level
capability (each granted action only takes effect while the recipient
holds that action in the org), so workspace sharing is ineffective for
(and rejected toward) users without workspace capabilities, evaluated
live on every authorization.~~ Tabled — excluded from the
gateway-accounts MVP.
Related but independent: **#27278** hides the Workspaces page create
CTAs for users without workspace-create permission.
## Benchmarks
`BenchmarkCountWorkspaceCapableUsers` (in `usercount_bench_test.go`, run
manually with `go test ./enterprise/coderd/license/ -bench
BenchmarkCountWorkspaceCapableUsers -benchtime 5x -run '^$'` — never
executed by CI) measures the count across user-scale and role-diversity
shapes:
| Scenario | Users | ~Unique subjects | per count |
|---|---|---|---|
| Uniform | 1k | 4 | 8.5ms |
| Uniform | 10k | 4 | 71ms |
| Uniform | 50k | 4 | 344ms |
| ManyOrgs (100 orgs) | 10k | ~200 | 112ms |
| CustomRoles (1000 org-scoped roles) | 10k | ~1000 | 168ms |
| UniquePairs (every user a distinct subject) | 10k | ~10,000 | 2.66s |
Summary:
- **Row-side cost is ~7µs per user, linear** (role parsing, subject
canonicalization, and sha256 per row). The bulk query + subject dedupe
handles 50k users in ~350ms; extrapolated 100k ≈ 0.7s. A non-issue at
the 10-minute refresh cadence.
- **Unique subjects are the dominant axis at ~0.26ms each** (role
expansion + one any-organization rego evaluation per subject). The
worst-case scenario — every user a distinct subject — costs ~2.7s at 10k
users, extrapolating to ~13s at 50k.
- **Realistic deployments sit near the cheap rows.** Subject diversity
tracks orgs × role/group combinations, not user count; only per-user
custom roles or per-user org-membership patterns approach the worst
case.
- Caveat encountered while building the harness: the roles query's plan
depends on accurate table statistics. With stale stats (e.g. right after
a bulk user import, before autovacuum ANALYZEs), the planner picks a
nested-loop plan that re-runs the aggregation per user row — a ~300×
regression (1.08s for 1k users). Fresh statistics restore the hash-join
plan; the harness ANALYZEs after seeding, so the numbers above reflect
the healthy plan.
761 lines
19 KiB
SQL
761 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: ListUserChatPersonalModelOverrides :many
|
|
SELECT key, value FROM user_configs
|
|
WHERE user_id = @user_id
|
|
AND key LIKE 'chat\_personal\_model\_override:%'
|
|
ORDER BY key;
|
|
|
|
-- name: GetUserChatPersonalModelOverride :one
|
|
SELECT value AS personal_model_override FROM user_configs
|
|
WHERE user_id = @user_id
|
|
AND key = @key;
|
|
|
|
-- name: UpsertUserChatPersonalModelOverride :exec
|
|
INSERT INTO user_configs (user_id, key, value)
|
|
VALUES (@user_id::uuid, @key::text, @value::text)
|
|
ON CONFLICT ON CONSTRAINT user_configs_pkey
|
|
DO UPDATE SET value = @value::text;
|
|
|
|
-- name: GetUserTaskNotificationAlertDismissed :one
|
|
SELECT
|
|
value::boolean as task_notification_alert_dismissed
|
|
FROM
|
|
user_configs
|
|
WHERE
|
|
user_id = @user_id
|
|
AND key = 'preference_task_notification_alert_dismissed';
|
|
|
|
-- name: UpdateUserTaskNotificationAlertDismissed :one
|
|
INSERT INTO
|
|
user_configs (user_id, key, value)
|
|
VALUES
|
|
(@user_id, 'preference_task_notification_alert_dismissed', (@task_notification_alert_dismissed::boolean)::text)
|
|
ON CONFLICT
|
|
ON CONSTRAINT user_configs_pkey
|
|
DO UPDATE
|
|
SET
|
|
value = @task_notification_alert_dismissed
|
|
WHERE user_configs.user_id = @user_id
|
|
AND user_configs.key = 'preference_task_notification_alert_dismissed'
|
|
RETURNING value::boolean AS task_notification_alert_dismissed;
|
|
|
|
-- 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: 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: 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 or username
|
|
AND CASE
|
|
WHEN @search :: text != '' THEN (
|
|
email ILIKE concat('%', @search, '%')
|
|
OR username 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.
|
|
unnest(
|
|
array_cat(
|
|
array_append(
|
|
roles,
|
|
CASE WHEN users.is_service_account THEN
|
|
'organization-service-account'
|
|
ELSE
|
|
'organization-member'
|
|
END
|
|
),
|
|
organizations.default_org_member_roles
|
|
)
|
|
) 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;
|