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:
Zach
2026-05-26 14:42:31 -06:00
committed by GitHub
parent d3155e1cab
commit 47ac4b309a
9 changed files with 672 additions and 28 deletions
+63
View File
@@ -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();