mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: enforce per-user limits on user_secrets (#25588)
Add a Postgres trigger and matching codersdk constants that cap each user's secrets in four dimensions: count (50), total stored value bytes (200 KiB), env-injected stored value bytes (24 KiB), and env name length (256 bytes). Without these caps a user could overflow the 4 MiB DRPC agent manifest, the ~32 KiB Windows process env block, or Linux/macOS ARG_MAX at workspace start. The trigger is the source of truth on aggregates; the handler maps its check_violation error into a 400 that names the per-user budget in stored (post-encryption) bytes. A handler test exercises off-by-one at each cap across POST and PATCH, plus per-user budget isolation. Generated with help from Coder Agents.
This commit is contained in:
Generated
+63
@@ -837,6 +837,67 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION enforce_user_secrets_per_user_limits() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
existing_count int;
|
||||
existing_total_bytes bigint;
|
||||
existing_env_bytes bigint;
|
||||
|
||||
new_count int;
|
||||
new_total_bytes bigint;
|
||||
new_env_bytes bigint;
|
||||
|
||||
count_limit constant int := 50;
|
||||
total_bytes_limit constant bigint := 204800; -- 200 KiB
|
||||
env_bytes_limit constant bigint := 24576; -- 24 KiB
|
||||
BEGIN
|
||||
-- Serialize cap checks per user so concurrent inserts cannot all
|
||||
-- observe the same pre-insert aggregates and exceed the cap.
|
||||
PERFORM 1 FROM users WHERE id = NEW.user_id FOR UPDATE;
|
||||
|
||||
-- Sum existing rows excluding the row being updated (so UPDATE statements
|
||||
-- don't double-count NEW). On INSERT, no row matches NEW.id, so
|
||||
-- the FILTER is a no-op.
|
||||
SELECT
|
||||
count(*) FILTER (WHERE id IS DISTINCT FROM NEW.id),
|
||||
coalesce(sum(octet_length(value)) FILTER (WHERE id IS DISTINCT FROM NEW.id), 0),
|
||||
coalesce(sum(octet_length(value)) FILTER (WHERE id IS DISTINCT FROM NEW.id AND env_name <> ''), 0)
|
||||
INTO existing_count, existing_total_bytes, existing_env_bytes
|
||||
FROM user_secrets
|
||||
WHERE user_id = NEW.user_id;
|
||||
|
||||
new_count := existing_count + 1;
|
||||
new_total_bytes := existing_total_bytes + octet_length(NEW.value);
|
||||
new_env_bytes := existing_env_bytes
|
||||
+ CASE WHEN NEW.env_name <> '' THEN octet_length(NEW.value) ELSE 0 END;
|
||||
|
||||
IF new_count > count_limit THEN
|
||||
RAISE EXCEPTION 'user has reached the user secrets count limit (% > %)',
|
||||
new_count, count_limit
|
||||
USING ERRCODE = 'check_violation',
|
||||
CONSTRAINT = 'user_secrets_per_user_count_limit';
|
||||
END IF;
|
||||
|
||||
IF new_total_bytes > total_bytes_limit THEN
|
||||
RAISE EXCEPTION 'user has reached the user secrets total value bytes limit (% > %)',
|
||||
new_total_bytes, total_bytes_limit
|
||||
USING ERRCODE = 'check_violation',
|
||||
CONSTRAINT = 'user_secrets_per_user_total_bytes_limit';
|
||||
END IF;
|
||||
|
||||
IF new_env_bytes > env_bytes_limit THEN
|
||||
RAISE EXCEPTION 'user has reached the env-injected user secrets bytes limit (% > %)',
|
||||
new_env_bytes, env_bytes_limit
|
||||
USING ERRCODE = 'check_violation',
|
||||
CONSTRAINT = 'user_secrets_per_user_env_bytes_limit';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION enforce_user_skills_per_user_limit() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
@@ -4398,6 +4459,8 @@ CREATE TRIGGER trigger_upsert_user_secrets BEFORE INSERT OR UPDATE ON user_secre
|
||||
|
||||
CREATE TRIGGER trigger_upsert_user_skills BEFORE INSERT OR UPDATE ON user_skills FOR EACH ROW EXECUTE FUNCTION insert_user_skill_fail_if_user_deleted();
|
||||
|
||||
CREATE TRIGGER trigger_user_secrets_per_user_limits BEFORE INSERT OR UPDATE ON user_secrets FOR EACH ROW EXECUTE FUNCTION enforce_user_secrets_per_user_limits();
|
||||
|
||||
CREATE TRIGGER trigger_user_skills_per_user_limit BEFORE INSERT ON user_skills FOR EACH ROW EXECUTE FUNCTION enforce_user_skills_per_user_limit();
|
||||
|
||||
CREATE TRIGGER update_notification_message_dedupe_hash BEFORE INSERT OR UPDATE ON notification_messages FOR EACH ROW EXECUTE FUNCTION compute_notification_message_dedupe_hash();
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TRIGGER IF EXISTS trigger_user_secrets_per_user_limits ON user_secrets;
|
||||
DROP FUNCTION IF EXISTS enforce_user_secrets_per_user_limits();
|
||||
@@ -0,0 +1,105 @@
|
||||
-- Per-user user_secrets caps (count, total stored bytes, env-injected
|
||||
-- stored bytes), enforced at the schema level.
|
||||
--
|
||||
-- Why: user_secrets is user-scoped; every workspace loads the same
|
||||
-- set via the agent manifest, and env-injected ones land in the
|
||||
-- agent's process env. Without a cap the failure surfaces at
|
||||
-- workspace start (or as a truncated env), not at create-time.
|
||||
--
|
||||
-- What drives each cap:
|
||||
--
|
||||
-- * count_limit = 50: backstop against row-count growth from many
|
||||
-- small secrets. The total_bytes_limit binds first for large
|
||||
-- secrets; this binds first for typical-sized ones (~few KB).
|
||||
--
|
||||
-- * total_bytes_limit = 200 KiB: sized to cover realistic
|
||||
-- credential storage (API keys, SSH keys, kubeconfigs, cert
|
||||
-- bundles) with headroom. Well under the 4 MiB DRPC manifest
|
||||
-- budget (codersdk/drpcsdk.MaxMessageSize).
|
||||
--
|
||||
-- * env_bytes_limit = 24 KiB: an approximate budget for the
|
||||
-- value bytes of env-injected secrets. Leaves ~8 KiB of
|
||||
-- headroom under the ~32 KiB Windows process env block
|
||||
-- (CreateProcessW's lpEnvironment is capped at 32,767
|
||||
-- characters) for what this aggregate does not count:
|
||||
-- env_name bytes, per-entry overhead, agent-injected vars
|
||||
-- (CODER_*, PATH, HOME, ...), and template-defined env. Not
|
||||
-- a strict overflow guarantee. Linux/macOS ARG_MAX (~2 MiB)
|
||||
-- is far above this, so the same cap works everywhere.
|
||||
--
|
||||
-- octet_length(value) measures stored bytes. In encrypted
|
||||
-- deployments stored bytes exceed plaintext (AES-GCM + base64
|
||||
-- ~1.33x). The handler's per-value check (UserSecretValueValid)
|
||||
-- measures plaintext separately, so it can pass while the
|
||||
-- trigger's stored-bytes aggregate rejects. The trigger is
|
||||
-- authoritative; the handler is a fast pre-flight.
|
||||
--
|
||||
-- Keep the literals below in sync with codersdk.MaxUserSecret*
|
||||
-- in codersdk/usersecretvalidation.go. TestUserSecretLimits in
|
||||
-- coderd/usersecrets_test.go exercises off-by-one for each cap,
|
||||
-- so any drift between the two layers fails an assertion.
|
||||
CREATE FUNCTION enforce_user_secrets_per_user_limits() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
existing_count int;
|
||||
existing_total_bytes bigint;
|
||||
existing_env_bytes bigint;
|
||||
|
||||
new_count int;
|
||||
new_total_bytes bigint;
|
||||
new_env_bytes bigint;
|
||||
|
||||
count_limit constant int := 50;
|
||||
total_bytes_limit constant bigint := 204800; -- 200 KiB
|
||||
env_bytes_limit constant bigint := 24576; -- 24 KiB
|
||||
BEGIN
|
||||
-- Serialize cap checks per user so concurrent inserts cannot all
|
||||
-- observe the same pre-insert aggregates and exceed the cap.
|
||||
PERFORM 1 FROM users WHERE id = NEW.user_id FOR UPDATE;
|
||||
|
||||
-- Sum existing rows excluding the row being updated (so UPDATE statements
|
||||
-- don't double-count NEW). On INSERT, no row matches NEW.id, so
|
||||
-- the FILTER is a no-op.
|
||||
SELECT
|
||||
count(*) FILTER (WHERE id IS DISTINCT FROM NEW.id),
|
||||
coalesce(sum(octet_length(value)) FILTER (WHERE id IS DISTINCT FROM NEW.id), 0),
|
||||
coalesce(sum(octet_length(value)) FILTER (WHERE id IS DISTINCT FROM NEW.id AND env_name <> ''), 0)
|
||||
INTO existing_count, existing_total_bytes, existing_env_bytes
|
||||
FROM user_secrets
|
||||
WHERE user_id = NEW.user_id;
|
||||
|
||||
new_count := existing_count + 1;
|
||||
new_total_bytes := existing_total_bytes + octet_length(NEW.value);
|
||||
new_env_bytes := existing_env_bytes
|
||||
+ CASE WHEN NEW.env_name <> '' THEN octet_length(NEW.value) ELSE 0 END;
|
||||
|
||||
IF new_count > count_limit THEN
|
||||
RAISE EXCEPTION 'user has reached the user secrets count limit (% > %)',
|
||||
new_count, count_limit
|
||||
USING ERRCODE = 'check_violation',
|
||||
CONSTRAINT = 'user_secrets_per_user_count_limit';
|
||||
END IF;
|
||||
|
||||
IF new_total_bytes > total_bytes_limit THEN
|
||||
RAISE EXCEPTION 'user has reached the user secrets total value bytes limit (% > %)',
|
||||
new_total_bytes, total_bytes_limit
|
||||
USING ERRCODE = 'check_violation',
|
||||
CONSTRAINT = 'user_secrets_per_user_total_bytes_limit';
|
||||
END IF;
|
||||
|
||||
IF new_env_bytes > env_bytes_limit THEN
|
||||
RAISE EXCEPTION 'user has reached the env-injected user secrets bytes limit (% > %)',
|
||||
new_env_bytes, env_bytes_limit
|
||||
USING ERRCODE = 'check_violation',
|
||||
CONSTRAINT = 'user_secrets_per_user_env_bytes_limit';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trigger_user_secrets_per_user_limits
|
||||
BEFORE INSERT OR UPDATE ON user_secrets
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE enforce_user_secrets_per_user_limits();
|
||||
Reference in New Issue
Block a user