diff --git a/.sqlx/query-038f2c0f8681c6c6710380acf0871cb3b382755f4b8d4d119035784581547f28.json b/.sqlx/query-038f2c0f8681c6c6710380acf0871cb3b382755f4b8d4d119035784581547f28.json new file mode 100644 index 00000000..f2de7cfe --- /dev/null +++ b/.sqlx/query-038f2c0f8681c6c6710380acf0871cb3b382755f4b8d4d119035784581547f28.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT jsonb_build_object('users', (SELECT jsonb_agg(u ORDER BY id) FROM users u),\n 'assignments', (SELECT jsonb_agg(a ORDER BY id) FROM assignments a),\n 'clients', (SELECT jsonb_agg(c ORDER BY id) FROM clients c),\n 'projects', (SELECT jsonb_agg(p ORDER BY id) FROM projects p),\n 'tasks', (SELECT jsonb_agg(t ORDER BY id) FROM tasks t))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "jsonb_build_object", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "038f2c0f8681c6c6710380acf0871cb3b382755f4b8d4d119035784581547f28" +} diff --git a/.sqlx/query-07de8e12eb9c9d55562901a6b332f45ce55612b71876cbe9b64d73fbfc625490.json b/.sqlx/query-07de8e12eb9c9d55562901a6b332f45ce55612b71876cbe9b64d73fbfc625490.json new file mode 100644 index 00000000..71f4dcb1 --- /dev/null +++ b/.sqlx/query-07de8e12eb9c9d55562901a6b332f45ce55612b71876cbe9b64d73fbfc625490.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE permission_templates SET catalog_version = 2 WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "07de8e12eb9c9d55562901a6b332f45ce55612b71876cbe9b64d73fbfc625490" +} diff --git a/.sqlx/query-09f7a01f6869210b69ad43faf1c1a070fdbb1143a1e3309e7bb4dc871db8f4a1.json b/.sqlx/query-09f7a01f6869210b69ad43faf1c1a070fdbb1143a1e3309e7bb4dc871db8f4a1.json new file mode 100644 index 00000000..9f8d8472 --- /dev/null +++ b/.sqlx/query-09f7a01f6869210b69ad43faf1c1a070fdbb1143a1e3309e7bb4dc871db8f4a1.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT audit FROM permission_change_receipts WHERE org_id = $1 ORDER BY created_at, id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "audit", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "09f7a01f6869210b69ad43faf1c1a070fdbb1143a1e3309e7bb4dc871db8f4a1" +} diff --git a/.sqlx/query-0b2b73d890bae732bb2f9a0022613041cfb1fcf63f80a5a752b7eb8155e4fc43.json b/.sqlx/query-0b2b73d890bae732bb2f9a0022613041cfb1fcf63f80a5a752b7eb8155e4fc43.json new file mode 100644 index 00000000..d692bba5 --- /dev/null +++ b/.sqlx/query-0b2b73d890bae732bb2f9a0022613041cfb1fcf63f80a5a752b7eb8155e4fc43.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "ALTER TABLE permission_change_receipts ADD CONSTRAINT injected_failure CHECK (false) NOT VALID", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "0b2b73d890bae732bb2f9a0022613041cfb1fcf63f80a5a752b7eb8155e4fc43" +} diff --git a/.sqlx/query-14484490ddbb5058bf86357df7a62e27ee5abf3771596509633603b739e54241.json b/.sqlx/query-14484490ddbb5058bf86357df7a62e27ee5abf3771596509633603b739e54241.json new file mode 100644 index 00000000..bce6ef2e --- /dev/null +++ b/.sqlx/query-14484490ddbb5058bf86357df7a62e27ee5abf3771596509633603b739e54241.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM users WHERE id = $1 FOR SHARE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "14484490ddbb5058bf86357df7a62e27ee5abf3771596509633603b739e54241" +} diff --git a/.sqlx/query-1984c8431c7a47451478ef287b951bd637b3410aaf8e69ba023b00872bfccc7c.json b/.sqlx/query-1984c8431c7a47451478ef287b951bd637b3410aaf8e69ba023b00872bfccc7c.json new file mode 100644 index 00000000..aa63d0d8 --- /dev/null +++ b/.sqlx/query-1984c8431c7a47451478ef287b951bd637b3410aaf8e69ba023b00872bfccc7c.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "SET statement_timeout = '250ms'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "1984c8431c7a47451478ef287b951bd637b3410aaf8e69ba023b00872bfccc7c" +} diff --git a/.sqlx/query-1a7869b0290eacb0f9da70bd59dcc17ca744f53d2f6853dd53ef854c42669ca8.json b/.sqlx/query-1a7869b0290eacb0f9da70bd59dcc17ca744f53d2f6853dd53ef854c42669ca8.json new file mode 100644 index 00000000..b12f8ca3 --- /dev/null +++ b/.sqlx/query-1a7869b0290eacb0f9da70bd59dcc17ca744f53d2f6853dd53ef854c42669ca8.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM person_permission_states WHERE user_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "1a7869b0290eacb0f9da70bd59dcc17ca744f53d2f6853dd53ef854c42669ca8" +} diff --git a/.sqlx/query-1f22e6243746287eaa7dbf35fcfa0d0b1ebeb98f5973b33b8fd61e006708f0d0.json b/.sqlx/query-1f22e6243746287eaa7dbf35fcfa0d0b1ebeb98f5973b33b8fd61e006708f0d0.json new file mode 100644 index 00000000..133d46b8 --- /dev/null +++ b/.sqlx/query-1f22e6243746287eaa7dbf35fcfa0d0b1ebeb98f5973b33b8fd61e006708f0d0.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO person_permission_states (id, org_id, user_id, catalog_version, grants, is_administrator, source)\n SELECT $1, org_id, $2, catalog_version, grants, true, 'individual'\n FROM person_permission_states WHERE user_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "1f22e6243746287eaa7dbf35fcfa0d0b1ebeb98f5973b33b8fd61e006708f0d0" +} diff --git a/.sqlx/query-1f964f71ab41b467c50954902448a931765844550c9bc0d63b39ba8dc1998b8a.json b/.sqlx/query-1f964f71ab41b467c50954902448a931765844550c9bc0d63b39ba8dc1998b8a.json new file mode 100644 index 00000000..10b06849 --- /dev/null +++ b/.sqlx/query-1f964f71ab41b467c50954902448a931765844550c9bc0d63b39ba8dc1998b8a.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT current_setting('statement_timeout') AS timeout, current_setting('default_transaction_read_only') AS readonly", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "timeout", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "readonly", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null, + null + ] + }, + "hash": "1f964f71ab41b467c50954902448a931765844550c9bc0d63b39ba8dc1998b8a" +} diff --git a/.sqlx/query-2156518cf5cdbeff4f3b75f8d02086ec9e818c45db876b33214c7b38f2aedc26.json b/.sqlx/query-2156518cf5cdbeff4f3b75f8d02086ec9e818c45db876b33214c7b38f2aedc26.json new file mode 100644 index 00000000..8aeba120 --- /dev/null +++ b/.sqlx/query-2156518cf5cdbeff4f3b75f8d02086ec9e818c45db876b33214c7b38f2aedc26.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE person_permission_states SET is_administrator = false, grants = $2 WHERE user_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "2156518cf5cdbeff4f3b75f8d02086ec9e818c45db876b33214c7b38f2aedc26" +} diff --git a/.sqlx/query-21a0875d01d5ca01384a65cf3d770eee7c396f6af15773c78e4b312e7c10015c.json b/.sqlx/query-21a0875d01d5ca01384a65cf3d770eee7c396f6af15773c78e4b312e7c10015c.json new file mode 100644 index 00000000..f1f9ac13 --- /dev/null +++ b/.sqlx/query-21a0875d01d5ca01384a65cf3d770eee7c396f6af15773c78e4b312e7c10015c.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE permission_change_receipts SET format_version = 2 WHERE org_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "21a0875d01d5ca01384a65cf3d770eee7c396f6af15773c78e4b312e7c10015c" +} diff --git a/.sqlx/query-2689c0b5fcd82380ac2771c4e09d24527b55ee55351c4bcc18638ff362cb4f99.json b/.sqlx/query-2689c0b5fcd82380ac2771c4e09d24527b55ee55351c4bcc18638ff362cb4f99.json new file mode 100644 index 00000000..ae17bca1 --- /dev/null +++ b/.sqlx/query-2689c0b5fcd82380ac2771c4e09d24527b55ee55351c4bcc18638ff362cb4f99.json @@ -0,0 +1,36 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT format_version, intent, result FROM permission_change_receipts\n WHERE org_id = $1 AND actor_user_id = $2 AND request_id = $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "format_version", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "intent", + "type_info": "Jsonb" + }, + { + "ordinal": 2, + "name": "result", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "2689c0b5fcd82380ac2771c4e09d24527b55ee55351c4bcc18638ff362cb4f99" +} diff --git a/.sqlx/query-283c331f9c9c3bdb3659ca4e9b4684a65ced6e031031b0cb3969b361bba565dc.json b/.sqlx/query-283c331f9c9c3bdb3659ca4e9b4684a65ced6e031031b0cb3969b361bba565dc.json new file mode 100644 index 00000000..15dc9803 --- /dev/null +++ b/.sqlx/query-283c331f9c9c3bdb3659ca4e9b4684a65ced6e031031b0cb3969b361bba565dc.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE person_permission_states SET source = 'template', template_id = $2, applied_template_revision = 0 WHERE user_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "283c331f9c9c3bdb3659ca4e9b4684a65ced6e031031b0cb3969b361bba565dc" +} diff --git a/.sqlx/query-283f59b0f2584765957d1858dfc923a9025632c8839c3901a86f4a6b597d589d.json b/.sqlx/query-283f59b0f2584765957d1858dfc923a9025632c8839c3901a86f4a6b597d589d.json new file mode 100644 index 00000000..c7ebfe4a --- /dev/null +++ b/.sqlx/query-283f59b0f2584765957d1858dfc923a9025632c8839c3901a86f4a6b597d589d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE person_permission_states SET grants = $2 WHERE user_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "283f59b0f2584765957d1858dfc923a9025632c8839c3901a86f4a6b597d589d" +} diff --git a/.sqlx/query-2cb0cb4d63224a262bdcedeaeeb17494fd94b51709dc85a37ce72c412817e4af.json b/.sqlx/query-2cb0cb4d63224a262bdcedeaeeb17494fd94b51709dc85a37ce72c412817e4af.json new file mode 100644 index 00000000..2737ff25 --- /dev/null +++ b/.sqlx/query-2cb0cb4d63224a262bdcedeaeeb17494fd94b51709dc85a37ce72c412817e4af.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT active FROM users WHERE org_id = $1 AND id = $2 FOR SHARE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "active", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "2cb0cb4d63224a262bdcedeaeeb17494fd94b51709dc85a37ce72c412817e4af" +} diff --git a/.sqlx/query-3be1fdf418f8277e8f53dc966aed2ef1a93e5f08e0904b95981bfa9d6256a7f2.json b/.sqlx/query-3be1fdf418f8277e8f53dc966aed2ef1a93e5f08e0904b95981bfa9d6256a7f2.json new file mode 100644 index 00000000..0f9de7be --- /dev/null +++ b/.sqlx/query-3be1fdf418f8277e8f53dc966aed2ef1a93e5f08e0904b95981bfa9d6256a7f2.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE person_permission_states SET source = 'template', template_id = $2, applied_template_revision = 0 WHERE org_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "3be1fdf418f8277e8f53dc966aed2ef1a93e5f08e0904b95981bfa9d6256a7f2" +} diff --git a/.sqlx/query-3da2a6ee73ebd445ff6eadef4c65ad24eac3f6a0ae8f3da1da12c5abfd14ad34.json b/.sqlx/query-3da2a6ee73ebd445ff6eadef4c65ad24eac3f6a0ae8f3da1da12c5abfd14ad34.json new file mode 100644 index 00000000..79522b25 --- /dev/null +++ b/.sqlx/query-3da2a6ee73ebd445ff6eadef4c65ad24eac3f6a0ae8f3da1da12c5abfd14ad34.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE organizations SET permission_policy_version = 1 WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "3da2a6ee73ebd445ff6eadef4c65ad24eac3f6a0ae8f3da1da12c5abfd14ad34" +} diff --git a/.sqlx/query-54f8ca93b4a854df9460ca6953dc4488d95c98c17a9b9d3dd4f0d0a61cb4f15f.json b/.sqlx/query-54f8ca93b4a854df9460ca6953dc4488d95c98c17a9b9d3dd4f0d0a61cb4f15f.json new file mode 100644 index 00000000..32e8702e --- /dev/null +++ b/.sqlx/query-54f8ca93b4a854df9460ca6953dc4488d95c98c17a9b9d3dd4f0d0a61cb4f15f.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "LOCK TABLE permission_change_receipts IN ACCESS EXCLUSIVE MODE", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "54f8ca93b4a854df9460ca6953dc4488d95c98c17a9b9d3dd4f0d0a61cb4f15f" +} diff --git a/.sqlx/query-581589e62c7fd2f71577ed78f3a7c867fdab6e19c90a352781308ef3a1b6d4a0.json b/.sqlx/query-581589e62c7fd2f71577ed78f3a7c867fdab6e19c90a352781308ef3a1b6d4a0.json new file mode 100644 index 00000000..f34387b0 --- /dev/null +++ b/.sqlx/query-581589e62c7fd2f71577ed78f3a7c867fdab6e19c90a352781308ef3a1b6d4a0.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE organizations SET access_revision = $2 WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "581589e62c7fd2f71577ed78f3a7c867fdab6e19c90a352781308ef3a1b6d4a0" +} diff --git a/.sqlx/query-5c1c838b6d0cf7ebae239c75027ef6808b180d274adf5582c6cc11f765dd7651.json b/.sqlx/query-5c1c838b6d0cf7ebae239c75027ef6808b180d274adf5582c6cc11f765dd7651.json new file mode 100644 index 00000000..4202f4ac --- /dev/null +++ b/.sqlx/query-5c1c838b6d0cf7ebae239c75027ef6808b180d274adf5582c6cc11f765dd7651.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO person_permission_states (id, org_id, user_id, catalog_version,\n grants, is_administrator, source, template_id, applied_template_revision)\n VALUES ($1, $2, $3, 1, ARRAY['unknown'], false, 'template', $4, 0)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "5c1c838b6d0cf7ebae239c75027ef6808b180d274adf5582c6cc11f765dd7651" +} diff --git a/.sqlx/query-5c835affd38ed30d6e8de4f037878033849313d6c645c901a99fe5ff058e77ed.json b/.sqlx/query-5c835affd38ed30d6e8de4f037878033849313d6c645c901a99fe5ff058e77ed.json new file mode 100644 index 00000000..53c6157f --- /dev/null +++ b/.sqlx/query-5c835affd38ed30d6e8de4f037878033849313d6c645c901a99fe5ff058e77ed.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO users (id, org_id, email, name) VALUES ($1, $2, 'other@example.test', 'Other')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "5c835affd38ed30d6e8de4f037878033849313d6c645c901a99fe5ff058e77ed" +} diff --git a/.sqlx/query-600e3b359d2a092edf62cb7665ec21d13f44b13fa40bf525f47872ddb01802f4.json b/.sqlx/query-600e3b359d2a092edf62cb7665ec21d13f44b13fa40bf525f47872ddb01802f4.json new file mode 100644 index 00000000..d7fbf08b --- /dev/null +++ b/.sqlx/query-600e3b359d2a092edf62cb7665ec21d13f44b13fa40bf525f47872ddb01802f4.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT access_revision, (SELECT count(*) FROM permission_templates WHERE org_id = $1) AS count\n FROM organizations WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "access_revision", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + null + ] + }, + "hash": "600e3b359d2a092edf62cb7665ec21d13f44b13fa40bf525f47872ddb01802f4" +} diff --git a/.sqlx/query-677e6eda260adf02ad17cce3d8d2740810cdc0b69db6b994891102e4ab3bd3e8.json b/.sqlx/query-677e6eda260adf02ad17cce3d8d2740810cdc0b69db6b994891102e4ab3bd3e8.json new file mode 100644 index 00000000..4203e523 --- /dev/null +++ b/.sqlx/query-677e6eda260adf02ad17cce3d8d2740810cdc0b69db6b994891102e4ab3bd3e8.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT user_id, catalog_version, grants, is_administrator, applied_template_revision, revision\n FROM person_permission_states WHERE org_id = $1 AND template_id = $2 ORDER BY user_id FOR UPDATE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "catalog_version", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "grants", + "type_info": "TextArray" + }, + { + "ordinal": 3, + "name": "is_administrator", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "applied_template_revision", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "revision", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + false + ] + }, + "hash": "677e6eda260adf02ad17cce3d8d2740810cdc0b69db6b994891102e4ab3bd3e8" +} diff --git a/.sqlx/query-6cb1975b0fc57e994c0b8ab7e39b8599ad62756d4859e4c94cebd41dfafdde9f.json b/.sqlx/query-6cb1975b0fc57e994c0b8ab7e39b8599ad62756d4859e4c94cebd41dfafdde9f.json new file mode 100644 index 00000000..17479c6b --- /dev/null +++ b/.sqlx/query-6cb1975b0fc57e994c0b8ab7e39b8599ad62756d4859e4c94cebd41dfafdde9f.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT permission_policy_version, access_revision FROM organizations WHERE id = $1 FOR UPDATE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "permission_policy_version", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "access_revision", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "6cb1975b0fc57e994c0b8ab7e39b8599ad62756d4859e4c94cebd41dfafdde9f" +} diff --git a/.sqlx/query-742b46b1fb7f699587b5bf9b8608f389acf30238dd8f2738b29d42eb1b89026e.json b/.sqlx/query-742b46b1fb7f699587b5bf9b8608f389acf30238dd8f2738b29d42eb1b89026e.json new file mode 100644 index 00000000..5ffc3bee --- /dev/null +++ b/.sqlx/query-742b46b1fb7f699587b5bf9b8608f389acf30238dd8f2738b29d42eb1b89026e.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO assignments (id, project_id, user_id, role, rate_cents) VALUES ($1, $2, $3, 'lead', 12345)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "742b46b1fb7f699587b5bf9b8608f389acf30238dd8f2738b29d42eb1b89026e" +} diff --git a/.sqlx/query-7fc66f086f79dae4d26de63187c57feb8287400925a60c9d342c2dacb6e45650.json b/.sqlx/query-7fc66f086f79dae4d26de63187c57feb8287400925a60c9d342c2dacb6e45650.json new file mode 100644 index 00000000..91ad0e8b --- /dev/null +++ b/.sqlx/query-7fc66f086f79dae4d26de63187c57feb8287400925a60c9d342c2dacb6e45650.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO person_permission_states\n (id, org_id, user_id, catalog_version, grants, is_administrator, source)\n VALUES ($1, $2, $3, 1, $4, true, 'individual')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "7fc66f086f79dae4d26de63187c57feb8287400925a60c9d342c2dacb6e45650" +} diff --git a/.sqlx/query-8dd2f0df7d83a57c75b27982b9fb4965ae9e6ae748706404e90766c7df9c03df.json b/.sqlx/query-8dd2f0df7d83a57c75b27982b9fb4965ae9e6ae748706404e90766c7df9c03df.json new file mode 100644 index 00000000..c1980e9b --- /dev/null +++ b/.sqlx/query-8dd2f0df7d83a57c75b27982b9fb4965ae9e6ae748706404e90766c7df9c03df.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO permission_change_receipts\n (id, org_id, actor_user_id, request_id, format_version, intent, result, audit)\n VALUES ($1, $2, $3, $4, 1, $5, $6, $7)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "Uuid", + "Jsonb", + "Jsonb", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "8dd2f0df7d83a57c75b27982b9fb4965ae9e6ae748706404e90766c7df9c03df" +} diff --git a/.sqlx/query-ac6a51e92eed517e162ee93f4a6ca2b25161f8307c2593e7b3553459f6cca99d.json b/.sqlx/query-ac6a51e92eed517e162ee93f4a6ca2b25161f8307c2593e7b3553459f6cca99d.json new file mode 100644 index 00000000..8ecff221 --- /dev/null +++ b/.sqlx/query-ac6a51e92eed517e162ee93f4a6ca2b25161f8307c2593e7b3553459f6cca99d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO users (id, org_id, email, name) VALUES ($1, $2, 'invalid@example.test', 'Invalid')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "ac6a51e92eed517e162ee93f4a6ca2b25161f8307c2593e7b3553459f6cca99d" +} diff --git a/.sqlx/query-ae3514eff181866b83d00b87461e656a6c629bda9971b6437e1a6022f638661a.json b/.sqlx/query-ae3514eff181866b83d00b87461e656a6c629bda9971b6437e1a6022f638661a.json new file mode 100644 index 00000000..c8a520a0 --- /dev/null +++ b/.sqlx/query-ae3514eff181866b83d00b87461e656a6c629bda9971b6437e1a6022f638661a.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE person_permission_states SET source = 'template', template_id = $2, applied_template_revision = 0, revision = $3 WHERE user_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "ae3514eff181866b83d00b87461e656a6c629bda9971b6437e1a6022f638661a" +} diff --git a/.sqlx/query-b6a18fd7679a8889c3d7e894f53d597cdd0421771c0849240b40c3cc553059ec.json b/.sqlx/query-b6a18fd7679a8889c3d7e894f53d597cdd0421771c0849240b40c3cc553059ec.json new file mode 100644 index 00000000..f13a5091 --- /dev/null +++ b/.sqlx/query-b6a18fd7679a8889c3d7e894f53d597cdd0421771c0849240b40c3cc553059ec.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE person_permission_states SET source = 'individual', template_id = NULL,\n applied_template_revision = NULL, built_in_profile = NULL, revision = revision + 1\n WHERE org_id = $1 AND template_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "b6a18fd7679a8889c3d7e894f53d597cdd0421771c0849240b40c3cc553059ec" +} diff --git a/.sqlx/query-bd383cfad5886a83ccd8871546f8f87b29f698c92f858ccd013af77552ab525f.json b/.sqlx/query-bd383cfad5886a83ccd8871546f8f87b29f698c92f858ccd013af77552ab525f.json new file mode 100644 index 00000000..c90a97c8 --- /dev/null +++ b/.sqlx/query-bd383cfad5886a83ccd8871546f8f87b29f698c92f858ccd013af77552ab525f.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO person_permission_states (id, org_id, user_id, catalog_version, grants, is_administrator, source, revision)\n VALUES ($1, $2, $3, 1, $4, false, 'individual', 7)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "bd383cfad5886a83ccd8871546f8f87b29f698c92f858ccd013af77552ab525f" +} diff --git a/.sqlx/query-c4cc8d67803a074564f20a3f42440fcaa8be66e207490ad786c08c59c7aec2af.json b/.sqlx/query-c4cc8d67803a074564f20a3f42440fcaa8be66e207490ad786c08c59c7aec2af.json new file mode 100644 index 00000000..e732efa4 --- /dev/null +++ b/.sqlx/query-c4cc8d67803a074564f20a3f42440fcaa8be66e207490ad786c08c59c7aec2af.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE organizations SET permission_policy_version = $2 WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "c4cc8d67803a074564f20a3f42440fcaa8be66e207490ad786c08c59c7aec2af" +} diff --git a/.sqlx/query-ca22b1ef9afa6710684e57acc98fb9b8489385cbb1779a23b208134d1c4f426c.json b/.sqlx/query-ca22b1ef9afa6710684e57acc98fb9b8489385cbb1779a23b208134d1c4f426c.json new file mode 100644 index 00000000..f986d935 --- /dev/null +++ b/.sqlx/query-ca22b1ef9afa6710684e57acc98fb9b8489385cbb1779a23b208134d1c4f426c.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO users (id, org_id, email, name) VALUES ($1, $2, 'admin2@example.test', 'Other admin')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "ca22b1ef9afa6710684e57acc98fb9b8489385cbb1779a23b208134d1c4f426c" +} diff --git a/.sqlx/query-cad0679b29769800f14803e53a2e959a089bc07bcaeb15f9ff8b758d1b294a4b.json b/.sqlx/query-cad0679b29769800f14803e53a2e959a089bc07bcaeb15f9ff8b758d1b294a4b.json new file mode 100644 index 00000000..f99f8d46 --- /dev/null +++ b/.sqlx/query-cad0679b29769800f14803e53a2e959a089bc07bcaeb15f9ff8b758d1b294a4b.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO permission_change_receipts\n (id, org_id, actor_user_id, operator_id, operator_command, request_id, format_version, intent, result, audit)\n VALUES ($1, $2, $3, $4, $5, $6, 1, '{}', '{}', '{}')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "Text", + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "cad0679b29769800f14803e53a2e959a089bc07bcaeb15f9ff8b758d1b294a4b" +} diff --git a/.sqlx/query-df0b7813390602964f47249f767d2ea2873a08912473bb94ee21ba3544a55d3c.json b/.sqlx/query-df0b7813390602964f47249f767d2ea2873a08912473bb94ee21ba3544a55d3c.json new file mode 100644 index 00000000..6b898615 --- /dev/null +++ b/.sqlx/query-df0b7813390602964f47249f767d2ea2873a08912473bb94ee21ba3544a55d3c.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT name, catalog_version, grants, revision FROM permission_templates WHERE org_id = $1 AND id = $2 FOR UPDATE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "catalog_version", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "grants", + "type_info": "TextArray" + }, + { + "ordinal": 3, + "name": "revision", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "df0b7813390602964f47249f767d2ea2873a08912473bb94ee21ba3544a55d3c" +} diff --git a/.sqlx/query-eaa40396b7716f7000da9462928c410b7057ec970850699e28e9fca96e98d2eb.json b/.sqlx/query-eaa40396b7716f7000da9462928c410b7057ec970850699e28e9fca96e98d2eb.json new file mode 100644 index 00000000..a38bda42 --- /dev/null +++ b/.sqlx/query-eaa40396b7716f7000da9462928c410b7057ec970850699e28e9fca96e98d2eb.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM permission_templates WHERE org_id = $1 AND id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "eaa40396b7716f7000da9462928c410b7057ec970850699e28e9fca96e98d2eb" +} diff --git a/.sqlx/query-ebdff61a4677cb699e6f9d81a8b29d8e86062e3a006ff2df23cf57c61b89355a.json b/.sqlx/query-ebdff61a4677cb699e6f9d81a8b29d8e86062e3a006ff2df23cf57c61b89355a.json new file mode 100644 index 00000000..7cbafa11 --- /dev/null +++ b/.sqlx/query-ebdff61a4677cb699e6f9d81a8b29d8e86062e3a006ff2df23cf57c61b89355a.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE person_permission_states SET is_administrator = false WHERE user_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "ebdff61a4677cb699e6f9d81a8b29d8e86062e3a006ff2df23cf57c61b89355a" +} diff --git a/.sqlx/query-fa14185949ba3b94a066b27965c2df43d3b5ef4b384efa63568c75be97a7f308.json b/.sqlx/query-fa14185949ba3b94a066b27965c2df43d3b5ef4b384efa63568c75be97a7f308.json new file mode 100644 index 00000000..cfc8966a --- /dev/null +++ b/.sqlx/query-fa14185949ba3b94a066b27965c2df43d3b5ef4b384efa63568c75be97a7f308.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT jsonb_agg(s ORDER BY user_id) FROM person_permission_states s WHERE org_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "jsonb_agg", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "fa14185949ba3b94a066b27965c2df43d3b5ef4b384efa63568c75be97a7f308" +} diff --git a/crates/horae/migrations/0043_permission_change_receipts.sql b/crates/horae/migrations/0043_permission_change_receipts.sql new file mode 100644 index 00000000..5a5acf61 --- /dev/null +++ b/crates/horae/migrations/0043_permission_change_receipts.sql @@ -0,0 +1,27 @@ +-- Command history survives template deletion; snapshot IDs are not live FKs. +CREATE TABLE permission_change_receipts ( + id uuid PRIMARY KEY, + org_id uuid NOT NULL REFERENCES organizations(id), + actor_user_id uuid, + operator_id text, + operator_command text, + request_id uuid NOT NULL, + format_version integer NOT NULL CHECK (format_version > 0), + intent jsonb NOT NULL CHECK (jsonb_typeof(intent) = 'object'), + result jsonb NOT NULL CHECK (jsonb_typeof(result) = 'object'), + audit jsonb NOT NULL CHECK (jsonb_typeof(audit) = 'object'), + created_at timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY (org_id, actor_user_id) REFERENCES users(org_id, id), + CHECK ( + (actor_user_id IS NOT NULL AND operator_id IS NULL AND operator_command IS NULL) + OR (actor_user_id IS NULL AND operator_id IS NOT NULL AND operator_command IS NOT NULL + AND btrim(operator_id) <> '' AND btrim(operator_command) <> '') + ) +); + +CREATE UNIQUE INDEX permission_change_receipts_user_request_key + ON permission_change_receipts (org_id, actor_user_id, request_id) + WHERE actor_user_id IS NOT NULL; +CREATE UNIQUE INDEX permission_change_receipts_operator_request_key + ON permission_change_receipts (org_id, operator_id, request_id) + WHERE operator_id IS NOT NULL; diff --git a/crates/horae/src/models.rs b/crates/horae/src/models.rs index 30958d4e..01d8ee82 100644 --- a/crates/horae/src/models.rs +++ b/crates/horae/src/models.rs @@ -5,6 +5,8 @@ pub mod invoice; mod jobs; pub mod organization; #[cfg(feature = "server")] +pub(crate) mod permission_editor; +#[cfg(feature = "server")] pub(crate) mod permissions; pub mod project; pub mod project_creation; diff --git a/crates/horae/src/models/permission_editor.rs b/crates/horae/src/models/permission_editor.rs new file mode 100644 index 00000000..97618ef7 --- /dev/null +++ b/crates/horae/src/models/permission_editor.rs @@ -0,0 +1,35 @@ +//! Reusable-profile command values; callers supply authenticated authority separately. + +use horae_core::permissions::catalog::Permission; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Confirmed intent; actor identity is supplied separately by the server. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TemplateCommand { + pub request_id: Uuid, + pub expected_access_revision: i64, + pub action: TemplateAction, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum TemplateAction { + Create { + name: String, + grants: Vec, + }, + Delete { + id: Uuid, + expected_revision: i64, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TemplateOutcome { + pub template_id: Uuid, + pub access_revision: i64, + pub detached_people: usize, +} diff --git a/crates/horae/src/server_fns/permissions.rs b/crates/horae/src/server_fns/permissions.rs index 7ce83e28..7d587702 100644 --- a/crates/horae/src/server_fns/permissions.rs +++ b/crates/horae/src/server_fns/permissions.rs @@ -1,4 +1,4 @@ -//! Strict internal storage reads, not authorization checks or mutation endpoints. +//! Strict permission storage and internal commands; policy activation is separate. use horae_core::permissions::catalog::{Permission, PermissionSelection, StoredPermissionError}; use serde::{Deserialize, de::IntoDeserializer}; @@ -7,6 +7,36 @@ use uuid::Uuid; use crate::models::permissions::{PermissionSource, PermissionTemplate, PersonPermissions}; +pub(crate) mod templates; + +/// Bound authorization transactions independently of pooled connection defaults. +pub(super) async fn configure_administration( + connection: &mut PgConnection, +) -> Result<(), sqlx::Error> { + sqlx::query!("SET TRANSACTION ISOLATION LEVEL READ COMMITTED, READ WRITE") + .execute(&mut *connection) + .await?; + configure_transaction_limits(connection).await +} + +/// Set local limits only after the caller selects its transaction isolation. +pub(super) async fn configure_transaction_limits( + connection: &mut PgConnection, +) -> Result<(), sqlx::Error> { + sqlx::query!( + "SELECT set_config(name, + (CASE WHEN setting::bigint = 0 THEN limits.milliseconds + ELSE LEAST(setting::bigint, limits.milliseconds) END)::text, true) + FROM pg_settings + JOIN (VALUES ('statement_timeout', 5000::bigint), + ('idle_in_transaction_session_timeout', 10000::bigint)) + AS limits(setting_name, milliseconds) ON name = limits.setting_name" + ) + .fetch_all(connection) + .await?; + Ok(()) +} + #[derive(Debug, thiserror::Error)] pub(crate) enum PermissionStorageError { #[error(transparent)] @@ -125,3 +155,7 @@ pub(crate) async fn load_permission_template( #[cfg(test)] #[path = "permissions/tests/storage.rs"] mod storage_tests; + +#[cfg(test)] +#[path = "permissions/tests/templates.rs"] +mod template_tests; diff --git a/crates/horae/src/server_fns/permissions/templates.rs b/crates/horae/src/server_fns/permissions/templates.rs new file mode 100644 index 00000000..fdd275cb --- /dev/null +++ b/crates/horae/src/server_fns/permissions/templates.rs @@ -0,0 +1,321 @@ +//! Transactional reusable-profile commands; policy activation remains separate. + +use horae_core::permissions::catalog::{ + InvalidTemplateName, PERMISSION_CATALOG_VERSION, Permission, PermissionSelection, + StoredPermissionError, validate_template_name, +}; +use serde::{Deserialize, Serialize}; +use sqlx::{PgConnection, PgPool}; +use uuid::Uuid; + +use super::{PermissionStorageError, load_person_permissions, restore_grants}; + +pub(crate) use crate::models::permission_editor::{ + TemplateAction, TemplateCommand, TemplateOutcome, +}; + +#[derive(Debug, thiserror::Error)] +pub(crate) enum TemplateCommandError { + #[error("Current administrator authority is required")] + Forbidden, + #[error("The permission state has changed; confirm again")] + Stale, + #[error("The request identifier was used for different intent")] + RequestConflict, + #[error("A profile with this name already exists")] + NameConflict, + #[error("At most 50 reusable profiles are allowed")] + Limit, + #[error("Profile not found")] + NotFound, + #[error("Permission revision exhausted")] + RevisionExhausted, + #[error("Unsupported stored command format")] + ReceiptVersion, + #[error(transparent)] + Name(#[from] InvalidTemplateName), + #[error(transparent)] + Selection(#[from] StoredPermissionError), + #[error(transparent)] + Storage(#[from] PermissionStorageError), + #[error(transparent)] + Database(#[from] sqlx::Error), + #[error(transparent)] + Json(#[from] serde_json::Error), +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct TemplateSnapshot { + id: Uuid, + name: String, + catalog_version: i32, + grants: Vec, + revision: i64, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct DetachedPerson { + user_id: Uuid, + catalog_version: i32, + grants: Vec, + is_administrator: bool, + previous_template_id: Uuid, + previous_applied_revision: i64, + previous_revision: i64, + revision: i64, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct TemplateAudit { + previous_access_revision: i64, + access_revision: i64, + before: Option, + after: Option, + /// Each entry transitions from its recorded template to individual provenance. + detached_people: Vec, +} + +fn increment(revision: i64) -> Result { + revision + .checked_add(1) + .ok_or(TemplateCommandError::RevisionExhausted) +} + +impl TemplateCommand { + fn canonical(&self) -> Result { + let action = match &self.action { + TemplateAction::Create { name, grants } => TemplateAction::Create { + name: validate_template_name(name)?.to_owned(), + grants: PermissionSelection::from_stored(PERMISSION_CATALOG_VERSION, grants)? + .iter() + .collect(), + }, + action @ TemplateAction::Delete { .. } => action.clone(), + }; + Ok(Self { + request_id: self.request_id, + expected_access_revision: self.expected_access_revision, + action, + }) + } +} + +/// Authenticates fresh canonical authority, then atomically changes state and history. +/// The organization gate precedes all locks; no user/project DML occurs here. +/// Callers must supply authenticated tenant/actor IDs, never client-selected authority. +pub(crate) async fn execute( + pool: &PgPool, + org_id: Uuid, + actor_id: Uuid, + request: &TemplateCommand, +) -> Result { + let mut tx = pool.begin().await?; + super::configure_administration(&mut tx).await?; + let org = sqlx::query!( + "SELECT permission_policy_version, access_revision FROM organizations WHERE id = $1 FOR UPDATE", org_id + ).fetch_optional(&mut *tx).await?.ok_or(TemplateCommandError::Forbidden)?; + let active = sqlx::query_scalar!( + "SELECT active FROM users WHERE org_id = $1 AND id = $2 FOR SHARE", + org_id, + actor_id + ) + .fetch_optional(&mut *tx) + .await?; + if org.permission_policy_version != 1 || active != Some(true) { + return Err(TemplateCommandError::Forbidden); + } + let authority = load_person_permissions(&mut tx, org_id, actor_id).await?; + if !authority.is_some_and(|state| state.is_administrator) { + return Err(TemplateCommandError::Forbidden); + } + let intent = request.canonical()?; + if let Some(receipt) = sqlx::query!( + "SELECT format_version, intent, result FROM permission_change_receipts + WHERE org_id = $1 AND actor_user_id = $2 AND request_id = $3", + org_id, + actor_id, + intent.request_id + ) + .fetch_optional(&mut *tx) + .await? + { + if receipt.format_version != 1 { + return Err(TemplateCommandError::ReceiptVersion); + } + if receipt.intent != serde_json::to_value(&intent)? { + return Err(TemplateCommandError::RequestConflict); + } + let result = serde_json::from_value(receipt.result)?; + tx.commit().await?; + return Ok(result); + } + if intent.expected_access_revision != org.access_revision { + return Err(TemplateCommandError::Stale); + } + let next_revision = increment(org.access_revision)?; + let (before, after, detached_people) = match &intent.action { + TemplateAction::Create { name, grants } => ( + None, + Some(create(&mut tx, org_id, name, grants).await?), + Vec::new(), + ), + TemplateAction::Delete { + id, + expected_revision, + } => { + let (before, people) = delete(&mut tx, org_id, *id, *expected_revision).await?; + (Some(before), None, people) + } + }; + let template_id = match (&before, &after) { + (Some(template), _) | (_, Some(template)) => template.id, + _ => return Err(TemplateCommandError::NotFound), + }; + let result = TemplateOutcome { + template_id, + access_revision: next_revision, + detached_people: detached_people.len(), + }; + let audit = TemplateAudit { + previous_access_revision: org.access_revision, + access_revision: next_revision, + before, + after, + detached_people, + }; + sqlx::query!( + "UPDATE organizations SET access_revision = $2 WHERE id = $1", + org_id, + next_revision + ) + .execute(&mut *tx) + .await?; + sqlx::query!( + "INSERT INTO permission_change_receipts + (id, org_id, actor_user_id, request_id, format_version, intent, result, audit) + VALUES ($1, $2, $3, $4, 1, $5, $6, $7)", + Uuid::now_v7(), + org_id, + actor_id, + intent.request_id, + serde_json::to_value(&intent)?, + serde_json::to_value(&result)?, + serde_json::to_value(&audit)? + ) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(result) +} + +async fn create( + connection: &mut PgConnection, + org_id: Uuid, + name: &str, + grants: &[Permission], +) -> Result { + let count = sqlx::query_scalar!( + "SELECT count(*) FROM permission_templates WHERE org_id = $1", + org_id + ) + .fetch_one(&mut *connection) + .await?; + if count.is_some_and(|count| count >= 50) { + return Err(TemplateCommandError::Limit); + } + let id = Uuid::now_v7(); + let grant_ids: Vec = serde_json::from_value(serde_json::to_value(grants)?)?; + sqlx::query!( + "INSERT INTO permission_templates (id, org_id, name, catalog_version, grants) + VALUES ($1, $2, $3, 1, $4)", + id, + org_id, + name, + &grant_ids + ) + .execute(connection) + .await + .map_err(|error| { + if error + .as_database_error() + .is_some_and(|error| error.constraint() == Some("permission_templates_org_name_key")) + { + TemplateCommandError::NameConflict + } else { + TemplateCommandError::Database(error) + } + })?; + Ok(TemplateSnapshot { + id, + name: name.to_owned(), + catalog_version: 1, + grants: grants.to_vec(), + revision: 0, + }) +} + +async fn delete( + connection: &mut PgConnection, + org_id: Uuid, + id: Uuid, + expected_revision: i64, +) -> Result<(TemplateSnapshot, Vec), TemplateCommandError> { + let row = sqlx::query!( + "SELECT name, catalog_version, grants, revision FROM permission_templates WHERE org_id = $1 AND id = $2 FOR UPDATE", org_id, id + ).fetch_optional(&mut *connection).await?.ok_or(TemplateCommandError::NotFound)?; + if row.revision != expected_revision { + return Err(TemplateCommandError::Stale); + } + let template = TemplateSnapshot { + id, + name: row.name, + catalog_version: row.catalog_version, + grants: restore_grants(row.catalog_version, &row.grants)? + .iter() + .collect(), + revision: row.revision, + }; + let rows = sqlx::query!( + "SELECT user_id, catalog_version, grants, is_administrator, applied_template_revision, revision + FROM person_permission_states WHERE org_id = $1 AND template_id = $2 ORDER BY user_id FOR UPDATE", org_id, id + ).fetch_all(&mut *connection).await?; + let people = rows + .into_iter() + .map(|row| { + Ok(DetachedPerson { + user_id: row.user_id, + catalog_version: row.catalog_version, + grants: restore_grants(row.catalog_version, &row.grants)? + .iter() + .collect(), + is_administrator: row.is_administrator, + previous_template_id: id, + previous_applied_revision: row + .applied_template_revision + .ok_or(PermissionStorageError::Provenance)?, + previous_revision: row.revision, + revision: increment(row.revision)?, + }) + }) + .collect::, TemplateCommandError>>()?; + sqlx::query!( + "UPDATE person_permission_states SET source = 'individual', template_id = NULL, + applied_template_revision = NULL, built_in_profile = NULL, revision = revision + 1 + WHERE org_id = $1 AND template_id = $2", + org_id, + id + ) + .execute(&mut *connection) + .await?; + sqlx::query!( + "DELETE FROM permission_templates WHERE org_id = $1 AND id = $2", + org_id, + id + ) + .execute(connection) + .await?; + Ok((template, people)) +} diff --git a/crates/horae/src/server_fns/permissions/tests/templates.rs b/crates/horae/src/server_fns/permissions/tests/templates.rs new file mode 100644 index 00000000..b61608e3 --- /dev/null +++ b/crates/horae/src/server_fns/permissions/tests/templates.rs @@ -0,0 +1,953 @@ +use super::templates::{TemplateAction, TemplateCommand, TemplateCommandError, execute}; +use super::*; +use crate::server_fns::test_seed::{SeedIds, seed, wait_for_blocked}; +use horae_core::permissions::catalog::BuiltInProfile; +use horae_core::types::OrgRole; +use sqlx::PgPool; +use std::time::Duration; + +async fn administrator(pool: &PgPool) -> SeedIds { + // Explicit identity, not the old role or equality with Administrator grants. + let ids = seed(pool, OrgRole::Member).await; + sqlx::query!( + "UPDATE organizations SET permission_policy_version = 1 WHERE id = $1", + ids.org_id + ) + .execute(pool) + .await + .unwrap(); + let grants: Vec = serde_json::from_value( + serde_json::to_value( + BuiltInProfile::Member + .selection() + .iter() + .collect::>(), + ) + .unwrap(), + ) + .unwrap(); + sqlx::query!( + "INSERT INTO person_permission_states + (id, org_id, user_id, catalog_version, grants, is_administrator, source) + VALUES ($1, $2, $3, 1, $4, true, 'individual')", + Uuid::now_v7(), + ids.org_id, + ids.user_id, + &grants + ) + .execute(pool) + .await + .unwrap(); + ids +} + +fn create(name: &str, expected_access_revision: i64) -> TemplateCommand { + TemplateCommand { + request_id: Uuid::now_v7(), + expected_access_revision, + action: TemplateAction::Create { + name: name.to_owned(), + grants: BuiltInProfile::Member.selection().iter().collect(), + }, + } +} + +fn delete(id: Uuid, expected_access_revision: i64) -> TemplateCommand { + TemplateCommand { + request_id: Uuid::now_v7(), + expected_access_revision, + action: TemplateAction::Delete { + id, + expected_revision: 0, + }, + } +} + +async fn revision_and_count(pool: &PgPool, org: Uuid) -> (i64, i64) { + let row = sqlx::query!( + "SELECT access_revision, (SELECT count(*) FROM permission_templates WHERE org_id = $1) AS count + FROM organizations WHERE id = $1", org + ).fetch_one(pool).await.unwrap(); + (row.access_revision, row.count.unwrap()) +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn template_command_waits_for_direct_actor_deactivation_on_replay(pool: PgPool) { + let ids = administrator(&pool).await; + let request = create("Team", 0); + execute(&pool, ids.org_id, ids.user_id, &request) + .await + .unwrap(); + let mut revoke = pool.begin().await.unwrap(); + sqlx::query!("UPDATE users SET active = false WHERE id = $1", ids.user_id) + .execute(&mut *revoke) + .await + .unwrap(); + let pid = sqlx::query_scalar!("SELECT pg_backend_pid()") + .fetch_one(&mut *revoke) + .await + .unwrap() + .unwrap(); + let pending_pool = pool.clone(); + let pending = + tokio::spawn( + async move { execute(&pending_pool, ids.org_id, ids.user_id, &request).await }, + ); + wait_for_blocked(&pool, pid).await; + revoke.commit().await.unwrap(); + assert!(matches!( + pending.await.unwrap(), + Err(TemplateCommandError::Forbidden) + )); + assert_eq!(revision_and_count(&pool, ids.org_id).await, (1, 1)); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn template_command_overrides_readonly_defaults_and_keeps_stricter_timeouts(pool: PgPool) { + let ids = administrator(&pool).await; + let restricted = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .after_connect(|connection, _| { + Box::pin(async move { + sqlx::query!("SET default_transaction_read_only = on") + .execute(&mut *connection) + .await?; + sqlx::query!("SET statement_timeout = '250ms'") + .execute(&mut *connection) + .await?; + Ok(()) + }) + }) + .connect_with(pool.connect_options().as_ref().clone()) + .await + .unwrap(); + let mut hold = pool.begin().await.unwrap(); + sqlx::query!("LOCK TABLE permission_change_receipts IN ACCESS EXCLUSIVE MODE") + .execute(&mut *hold) + .await + .unwrap(); + let request = create("Team", 0); + let error = tokio::time::timeout( + Duration::from_secs(2), + execute(&restricted, ids.org_id, ids.user_id, &request), + ) + .await + .unwrap() + .unwrap_err(); + let TemplateCommandError::Database(sqlx::Error::Database(error)) = error else { + panic!("Expected statement timeout: {error}") + }; + assert_eq!(error.code().as_deref(), Some("57014")); + hold.rollback().await.unwrap(); + let defaults = sqlx::query!("SELECT current_setting('statement_timeout') AS timeout, current_setting('default_transaction_read_only') AS readonly") + .fetch_one(&restricted).await.unwrap(); + assert_eq!(defaults.timeout.as_deref(), Some("250ms")); + assert_eq!(defaults.readonly.as_deref(), Some("on")); + assert_eq!(revision_and_count(&pool, ids.org_id).await, (0, 0)); + execute(&restricted, ids.org_id, ids.user_id, &request) + .await + .unwrap(); + restricted.close().await; +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn create_canonical_retry_returns_one_historical_change(pool: PgPool) { + let ids = administrator(&pool).await; + let mut command = create(" Equipo ", 0); + let first = execute(&pool, ids.org_id, ids.user_id, &command) + .await + .unwrap(); + if let TemplateAction::Create { name, grants } = &mut command.action { + *name = "Equipo".to_owned(); + grants.reverse(); + } + assert_eq!( + execute(&pool, ids.org_id, ids.user_id, &command) + .await + .unwrap(), + first + ); + let stored = load_permission_template( + &mut pool.acquire().await.unwrap(), + ids.org_id, + first.template_id, + ) + .await + .unwrap() + .unwrap(); + assert_eq!((stored.name.as_str(), stored.revision), ("Equipo", 0)); + assert_eq!(stored.grants, BuiltInProfile::Member.selection()); + assert_eq!(revision_and_count(&pool, ids.org_id).await, (1, 1)); + command.expected_access_revision = 1; + assert!(matches!( + execute(&pool, ids.org_id, ids.user_id, &command).await, + Err(TemplateCommandError::RequestConflict) + )); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn equivalent_names_and_invalid_confirmed_selections_roll_back(pool: PgPool) { + let ids = administrator(&pool).await; + execute(&pool, ids.org_id, ids.user_id, &create("Equipo", 0)) + .await + .unwrap(); + assert!(matches!( + execute(&pool, ids.org_id, ids.user_id, &create(" equipo ", 1)).await, + Err(TemplateCommandError::NameConflict) + )); + for name in [" ".to_owned(), "x".repeat(101)] { + assert!(matches!( + execute(&pool, ids.org_id, ids.user_id, &create(&name, 1)).await, + Err(TemplateCommandError::Name(_)) + )); + } + let floor: Vec<_> = BuiltInProfile::Member.selection().iter().collect(); + let mut duplicate = floor.clone(); + duplicate.push(Permission::TimeReadOwn); + let mut missing_prerequisite = floor; + missing_prerequisite.push(Permission::BillingWrite); + for grants in [vec![], duplicate, missing_prerequisite] { + let mut command = create("Valid", 1); + command.action = TemplateAction::Create { + name: "Valid".to_owned(), + grants, + }; + assert!(matches!( + execute(&pool, ids.org_id, ids.user_id, &command).await, + Err(TemplateCommandError::Selection(_)) + )); + } + assert_eq!(revision_and_count(&pool, ids.org_id).await, (1, 1)); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn delete_replay_does_not_touch_a_same_name_replacement(pool: PgPool) { + let ids = administrator(&pool).await; + let created = execute(&pool, ids.org_id, ids.user_id, &create("Equipo", 0)) + .await + .unwrap(); + let command = delete(created.template_id, 1); + let removed = execute(&pool, ids.org_id, ids.user_id, &command) + .await + .unwrap(); + let replacement = execute(&pool, ids.org_id, ids.user_id, &create("Equipo", 2)) + .await + .unwrap(); + assert_eq!( + execute(&pool, ids.org_id, ids.user_id, &command) + .await + .unwrap(), + removed + ); + assert_ne!(created.template_id, replacement.template_id); + assert!( + load_permission_template( + &mut pool.acquire().await.unwrap(), + ids.org_id, + replacement.template_id + ) + .await + .unwrap() + .is_some() + ); + assert_eq!(revision_and_count(&pool, ids.org_id).await, (3, 1)); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn legacy_future_missing_foreign_and_non_admin_authority_deny(pool: PgPool) { + let ids = administrator(&pool).await; + let other = administrator(&pool).await; + for actor in [other.user_id, Uuid::now_v7()] { + assert!(matches!( + execute(&pool, ids.org_id, actor, &create("Denied", 0)).await, + Err(TemplateCommandError::Forbidden) + )); + } + for mode in [0, 2] { + sqlx::query!( + "UPDATE organizations SET permission_policy_version = $2 WHERE id = $1", + ids.org_id, + mode + ) + .execute(&pool) + .await + .unwrap(); + assert!(matches!( + execute(&pool, ids.org_id, ids.user_id, &create("Denied", 0)).await, + Err(TemplateCommandError::Forbidden) + )); + } + sqlx::query!( + "UPDATE organizations SET permission_policy_version = 1 WHERE id = $1", + ids.org_id + ) + .execute(&pool) + .await + .unwrap(); + let grants: Vec = serde_json::from_value( + serde_json::to_value( + BuiltInProfile::Administrator + .selection() + .iter() + .collect::>(), + ) + .unwrap(), + ) + .unwrap(); + sqlx::query!("UPDATE person_permission_states SET is_administrator = false, grants = $2 WHERE user_id = $1", ids.user_id, &grants).execute(&pool).await.unwrap(); + assert!(matches!( + execute(&pool, ids.org_id, ids.user_id, &create("Denied", 0)).await, + Err(TemplateCommandError::Forbidden) + )); + sqlx::query!( + "DELETE FROM person_permission_states WHERE user_id = $1", + ids.user_id + ) + .execute(&pool) + .await + .unwrap(); + assert!(matches!( + execute(&pool, ids.org_id, ids.user_id, &create("Denied", 0)).await, + Err(TemplateCommandError::Forbidden) + )); + assert_eq!(revision_and_count(&pool, ids.org_id).await, (0, 0)); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn actor_share_lock_does_not_block_template_command(pool: PgPool) { + let ids = administrator(&pool).await; + let mut held = pool.begin().await.unwrap(); + sqlx::query!("SELECT id FROM users WHERE id = $1 FOR SHARE", ids.user_id) + .fetch_one(&mut *held) + .await + .unwrap(); + tokio::time::timeout( + Duration::from_secs(5), + execute(&pool, ids.org_id, ids.user_id, &create("Allowed", 0)), + ) + .await + .expect("no incompatible user lock") + .unwrap(); + held.rollback().await.unwrap(); +} + +async fn receipts(pool: &PgPool, org: Uuid) -> Vec { + sqlx::query_scalar!( + "SELECT audit FROM permission_change_receipts WHERE org_id = $1 ORDER BY created_at, id", + org + ) + .fetch_all(pool) + .await + .unwrap() +} + +async fn business_snapshot(pool: &PgPool) -> serde_json::Value { + sqlx::query_scalar!( + "SELECT jsonb_build_object('users', (SELECT jsonb_agg(u ORDER BY id) FROM users u), + 'assignments', (SELECT jsonb_agg(a ORDER BY id) FROM assignments a), + 'clients', (SELECT jsonb_agg(c ORDER BY id) FROM clients c), + 'projects', (SELECT jsonb_agg(p ORDER BY id) FROM projects p), + 'tasks', (SELECT jsonb_agg(t ORDER BY id) FROM tasks t))" + ) + .fetch_one(pool) + .await + .unwrap() + .unwrap() +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn delete_preserves_adjusted_grants_identity_and_business_rows(pool: PgPool) { + let ids = administrator(&pool).await; + let other = Uuid::now_v7(); + sqlx::query!("INSERT INTO users (id, org_id, email, name) VALUES ($1, $2, 'other@example.test', 'Other')", other, ids.org_id).execute(&pool).await.unwrap(); + let grants: Vec = serde_json::from_value( + serde_json::to_value( + BuiltInProfile::Accounting + .selection() + .iter() + .collect::>(), + ) + .unwrap(), + ) + .unwrap(); + sqlx::query!( + "INSERT INTO person_permission_states (id, org_id, user_id, catalog_version, grants, is_administrator, source, revision) + VALUES ($1, $2, $3, 1, $4, false, 'individual', 7)", Uuid::now_v7(), ids.org_id, other, &grants + ).execute(&pool).await.unwrap(); + sqlx::query!("INSERT INTO assignments (id, project_id, user_id, role, rate_cents) VALUES ($1, $2, $3, 'lead', 12345)", Uuid::now_v7(), ids.project_id, other).execute(&pool).await.unwrap(); + let snapshot = business_snapshot(&pool).await; + let created = execute(&pool, ids.org_id, ids.user_id, &create("Equipo", 0)) + .await + .unwrap(); + // Fixture represents previously confirmed applications with independent edits. + sqlx::query!("UPDATE person_permission_states SET source = 'template', template_id = $2, applied_template_revision = 0 WHERE org_id = $1", ids.org_id, created.template_id).execute(&pool).await.unwrap(); + let removed = execute( + &pool, + ids.org_id, + ids.user_id, + &delete(created.template_id, 1), + ) + .await + .unwrap(); + assert_eq!(removed.detached_people, 2); + for (user, selection, administrator, revision) in [ + (ids.user_id, BuiltInProfile::Member, true, 1), + (other, BuiltInProfile::Accounting, false, 8), + ] { + let state = load_person_permissions(&mut pool.acquire().await.unwrap(), ids.org_id, user) + .await + .unwrap() + .unwrap(); + assert_eq!( + state, + PersonPermissions { + grants: selection.selection(), + is_administrator: administrator, + source: PermissionSource::Individual, + revision + } + ); + } + assert_eq!(business_snapshot(&pool).await, snapshot); + let audit = receipts(&pool, ids.org_id).await; + assert_eq!(audit.len(), 2); + assert_eq!( + audit[1]["before"]["id"], + serde_json::json!(created.template_id) + ); + assert!(audit[1]["after"].is_null()); + let people = audit[1]["detached_people"].as_array().unwrap(); + assert_eq!(people.len(), 2); + for person in people { + let user: Uuid = serde_json::from_value(person["user_id"].clone()).unwrap(); + let (selection, admin, previous, revision) = if user == ids.user_id { + (BuiltInProfile::Member, true, 0, 1) + } else { + assert_eq!(user, other); + (BuiltInProfile::Accounting, false, 7, 8) + }; + assert_eq!( + person["grants"], + serde_json::to_value(selection.selection().iter().collect::>()).unwrap() + ); + assert_eq!(person["is_administrator"], admin); + assert_eq!( + person["previous_template_id"], + serde_json::json!(created.template_id) + ); + assert_eq!(person["previous_applied_revision"], 0); + assert_eq!(person["previous_revision"], previous); + assert_eq!(person["revision"], revision); + } +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn concurrent_creators_never_exceed_fifty_profiles(pool: PgPool) { + let ids = administrator(&pool).await; + for index in 0..49 { + execute( + &pool, + ids.org_id, + ids.user_id, + &create(&format!("Profile {index}"), index), + ) + .await + .unwrap(); + } + let first = create("First", 49); + let second = create("Second", 49); + let results = tokio::join!( + execute(&pool, ids.org_id, ids.user_id, &first), + execute(&pool, ids.org_id, ids.user_id, &second) + ); + assert!(matches!( + results, + (Ok(_), Err(TemplateCommandError::Stale)) | (Err(TemplateCommandError::Stale), Ok(_)) + )); + assert!(matches!( + execute(&pool, ids.org_id, ids.user_id, &create("Too many", 50)).await, + Err(TemplateCommandError::Limit) + )); + assert_eq!(revision_and_count(&pool, ids.org_id).await, (50, 50)); + assert_eq!(receipts(&pool, ids.org_id).await.len(), 50); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn stale_foreign_and_exhausted_revisions_never_partially_delete(pool: PgPool) { + let ids = administrator(&pool).await; + let other = administrator(&pool).await; + let created = execute(&pool, ids.org_id, ids.user_id, &create("Equipo", 0)) + .await + .unwrap(); + assert!(matches!( + execute(&pool, ids.org_id, ids.user_id, &create("Stale", 0)).await, + Err(TemplateCommandError::Stale) + )); + assert!(matches!( + execute( + &pool, + ids.org_id, + ids.user_id, + &delete(created.template_id, 0) + ) + .await, + Err(TemplateCommandError::Stale) + )); + assert!(matches!( + execute( + &pool, + other.org_id, + other.user_id, + &delete(created.template_id, 0) + ) + .await, + Err(TemplateCommandError::NotFound) + )); + let mut stale = delete(created.template_id, 1); + stale.action = TemplateAction::Delete { + id: created.template_id, + expected_revision: 2, + }; + assert!(matches!( + execute(&pool, ids.org_id, ids.user_id, &stale).await, + Err(TemplateCommandError::Stale) + )); + sqlx::query!("UPDATE person_permission_states SET source = 'template', template_id = $2, applied_template_revision = 0, revision = $3 WHERE user_id = $1", ids.user_id, created.template_id, i64::MAX).execute(&pool).await.unwrap(); + assert!(matches!( + execute( + &pool, + ids.org_id, + ids.user_id, + &delete(created.template_id, 1) + ) + .await, + Err(TemplateCommandError::RevisionExhausted) + )); + let state = + load_person_permissions(&mut pool.acquire().await.unwrap(), ids.org_id, ids.user_id) + .await + .unwrap() + .unwrap(); + assert_eq!( + state.source, + PermissionSource::Template { + id: created.template_id, + applied_revision: 0 + } + ); + assert_eq!(state.revision, i64::MAX); + assert_eq!(revision_and_count(&pool, ids.org_id).await, (1, 1)); + sqlx::query!( + "UPDATE organizations SET access_revision = $2 WHERE id = $1", + ids.org_id, + i64::MAX + ) + .execute(&pool) + .await + .unwrap(); + assert!(matches!( + execute( + &pool, + ids.org_id, + ids.user_id, + &create("Overflow", i64::MAX) + ) + .await, + Err(TemplateCommandError::RevisionExhausted) + )); + assert_eq!(revision_and_count(&pool, ids.org_id).await, (i64::MAX, 1)); + assert_eq!(receipts(&pool, ids.org_id).await.len(), 1); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn revocation_winning_org_gate_denies_pending_replay_and_new_command(pool: PgPool) { + for deactivate in [false, true] { + for replay in [false, true] { + let ids = administrator(&pool).await; + let request = create("Equipo", 0); + execute(&pool, ids.org_id, ids.user_id, &request) + .await + .unwrap(); + let request = if replay { request } else { create("New", 1) }; + let mut held = pool.begin().await.unwrap(); + let pid = sqlx::query_scalar!("SELECT pg_backend_pid()") + .fetch_one(&mut *held) + .await + .unwrap() + .unwrap(); + sqlx::query!( + "SELECT id FROM organizations WHERE id = $1 FOR UPDATE", + ids.org_id + ) + .fetch_one(&mut *held) + .await + .unwrap(); + let command_pool = pool.clone(); + let pending = tokio::spawn(async move { + execute(&command_pool, ids.org_id, ids.user_id, &request).await + }); + wait_for_blocked(&pool, pid).await; + if deactivate { + sqlx::query!("UPDATE users SET active = false WHERE id = $1", ids.user_id) + .execute(&mut *held) + .await + .unwrap(); + } else { + sqlx::query!("UPDATE person_permission_states SET is_administrator = false WHERE user_id = $1", ids.user_id).execute(&mut *held).await.unwrap(); + } + held.commit().await.unwrap(); + assert!(matches!( + pending.await.unwrap(), + Err(TemplateCommandError::Forbidden) + )); + assert_eq!(revision_and_count(&pool, ids.org_id).await, (1, 1)); + assert_eq!(receipts(&pool, ids.org_id).await.len(), 1); + } + } +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn invalid_stored_authority_and_template_grants_fail_closed(pool: PgPool) { + let ids = administrator(&pool).await; + let created = execute(&pool, ids.org_id, ids.user_id, &create("Equipo", 0)) + .await + .unwrap(); + for grants in [vec!["unknown".to_owned()], vec![]] { + sqlx::query!( + "UPDATE person_permission_states SET grants = $2 WHERE user_id = $1", + ids.user_id, + &grants + ) + .execute(&pool) + .await + .unwrap(); + assert!(matches!( + execute(&pool, ids.org_id, ids.user_id, &create("Denied", 1)).await, + Err(TemplateCommandError::Storage(_)) + )); + } + // Restore the actor, then corrupt the independently loaded template selection. + let grants: Vec = serde_json::from_value( + serde_json::to_value( + BuiltInProfile::Member + .selection() + .iter() + .collect::>(), + ) + .unwrap(), + ) + .unwrap(); + sqlx::query!( + "UPDATE person_permission_states SET grants = $2 WHERE user_id = $1", + ids.user_id, + &grants + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query!( + "UPDATE permission_templates SET catalog_version = 2 WHERE id = $1", + created.template_id + ) + .execute(&pool) + .await + .unwrap(); + assert!(matches!( + execute( + &pool, + ids.org_id, + ids.user_id, + &delete(created.template_id, 1) + ) + .await, + Err(TemplateCommandError::Storage(_)) + )); + assert_eq!(revision_and_count(&pool, ids.org_id).await, (1, 1)); + assert_eq!(receipts(&pool, ids.org_id).await.len(), 1); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn failed_audit_insert_rolls_back_creation_and_detachment(pool: PgPool) { + let ids = administrator(&pool).await; + let created = execute(&pool, ids.org_id, ids.user_id, &create("Equipo", 0)) + .await + .unwrap(); + sqlx::query!("UPDATE person_permission_states SET source = 'template', template_id = $2, applied_template_revision = 0 WHERE user_id = $1", ids.user_id, created.template_id).execute(&pool).await.unwrap(); + sqlx::query!("ALTER TABLE permission_change_receipts ADD CONSTRAINT injected_failure CHECK (false) NOT VALID").execute(&pool).await.unwrap(); + for request in [create("Rolled back", 1), delete(created.template_id, 1)] { + assert!(matches!( + execute(&pool, ids.org_id, ids.user_id, &request).await, + Err(TemplateCommandError::Database(_)) + )); + assert_eq!(revision_and_count(&pool, ids.org_id).await, (1, 1)); + assert_eq!(receipts(&pool, ids.org_id).await.len(), 1); + } + let state = + load_person_permissions(&mut pool.acquire().await.unwrap(), ids.org_id, ids.user_id) + .await + .unwrap() + .unwrap(); + assert_eq!( + (state.revision, state.source), + ( + 0, + PermissionSource::Template { + id: created.template_id, + applied_revision: 0 + } + ) + ); +} + +#[test] +fn unknown_grants_and_authority_fields_cannot_deserialize_as_commands() { + let request = create("Equipo", 0); + let mut value = serde_json::to_value(&request).unwrap(); + value["action"]["grants"] = serde_json::json!(["unknown"]); + assert!(serde_json::from_value::(value).is_err()); + let mut value = serde_json::to_value(request).unwrap(); + value["is_administrator"] = serde_json::json!(true); + assert!(serde_json::from_value::(value).is_err()); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn malformed_assignee_aborts_all_detachments(pool: PgPool) { + let ids = administrator(&pool).await; + let created = execute(&pool, ids.org_id, ids.user_id, &create("Equipo", 0)) + .await + .unwrap(); + let person = Uuid::now_v7(); + sqlx::query!("INSERT INTO users (id, org_id, email, name) VALUES ($1, $2, 'invalid@example.test', 'Invalid')", person, ids.org_id).execute(&pool).await.unwrap(); + sqlx::query!( + "INSERT INTO person_permission_states (id, org_id, user_id, catalog_version, + grants, is_administrator, source, template_id, applied_template_revision) + VALUES ($1, $2, $3, 1, ARRAY['unknown'], false, 'template', $4, 0)", + Uuid::now_v7(), + ids.org_id, + person, + created.template_id + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query!("UPDATE person_permission_states SET source = 'template', template_id = $2, applied_template_revision = 0 WHERE user_id = $1", ids.user_id, created.template_id).execute(&pool).await.unwrap(); + let before = sqlx::query_scalar!( + "SELECT jsonb_agg(s ORDER BY user_id) FROM person_permission_states s WHERE org_id = $1", + ids.org_id + ) + .fetch_one(&pool) + .await + .unwrap(); + assert!(matches!( + execute( + &pool, + ids.org_id, + ids.user_id, + &delete(created.template_id, 1) + ) + .await, + Err(TemplateCommandError::Storage( + PermissionStorageError::Identifier(_) + )) + )); + let after = sqlx::query_scalar!( + "SELECT jsonb_agg(s ORDER BY user_id) FROM person_permission_states s WHERE org_id = $1", + ids.org_id + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(after, before); + assert_eq!(revision_and_count(&pool, ids.org_id).await, (1, 1)); + assert_eq!(receipts(&pool, ids.org_id).await.len(), 1); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn concurrent_exact_retry_creates_one_receipt_without_changing_people(pool: PgPool) { + let ids = administrator(&pool).await; + let before = sqlx::query_scalar!( + "SELECT jsonb_agg(s ORDER BY user_id) FROM person_permission_states s WHERE org_id = $1", + ids.org_id + ) + .fetch_one(&pool) + .await + .unwrap(); + let request = create(" Equipo ", 0); + let (first, second) = tokio::join!( + execute(&pool, ids.org_id, ids.user_id, &request), + execute(&pool, ids.org_id, ids.user_id, &request) + ); + let outcome = first.unwrap(); + assert_eq!(second.unwrap(), outcome); + let after = sqlx::query_scalar!( + "SELECT jsonb_agg(s ORDER BY user_id) FROM person_permission_states s WHERE org_id = $1", + ids.org_id + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(after, before); + let audits = receipts(&pool, ids.org_id).await; + assert_eq!( + audits, + vec![serde_json::json!({ + "previous_access_revision": 0, "access_revision": 1, + "before": null, "after": { "id": outcome.template_id, "name": "Equipo", + "catalog_version": 1, "revision": 0, + "grants": BuiltInProfile::Member.selection().iter().collect::>() }, + "detached_people": [] + })] + ); + assert_eq!(revision_and_count(&pool, ids.org_id).await, (1, 1)); +} + +async fn insert_receipt( + pool: &PgPool, + org: Uuid, + principal: (Option, Option<&str>, Option<&str>), + request: Uuid, +) -> Result<(), sqlx::Error> { + sqlx::query!( + "INSERT INTO permission_change_receipts + (id, org_id, actor_user_id, operator_id, operator_command, request_id, format_version, intent, result, audit) + VALUES ($1, $2, $3, $4, $5, $6, 1, '{}', '{}', '{}')", + Uuid::now_v7(), org, principal.0, principal.1, principal.2, request + ).execute(pool).await?; + Ok(()) +} + +fn sqlstate(error: sqlx::Error, code: &str) { + assert_eq!( + error.as_database_error().unwrap().code().as_deref(), + Some(code) + ); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn receipts_enforce_tenant_exclusive_principal_and_request_uniqueness(pool: PgPool) { + let ids = administrator(&pool).await; + let other = administrator(&pool).await; + for principal in [ + (None, None, None), + (Some(ids.user_id), Some("operator"), Some("command")), + (Some(ids.user_id), None, Some("command")), + (None, Some("operator"), None), + (None, Some(" "), Some("command")), + (None, Some("operator"), Some(" ")), + ] { + sqlstate( + insert_receipt(&pool, ids.org_id, principal, Uuid::now_v7()) + .await + .unwrap_err(), + "23514", + ); + } + sqlstate( + insert_receipt( + &pool, + ids.org_id, + (Some(other.user_id), None, None), + Uuid::now_v7(), + ) + .await + .unwrap_err(), + "23503", + ); + let request = Uuid::now_v7(); + let user = (Some(ids.user_id), None, None); + let operator = (None, Some("operator"), Some("reconcile")); + insert_receipt(&pool, ids.org_id, user, request) + .await + .unwrap(); + insert_receipt(&pool, ids.org_id, operator, request) + .await + .unwrap(); + sqlstate( + insert_receipt(&pool, ids.org_id, user, request) + .await + .unwrap_err(), + "23505", + ); + sqlstate( + insert_receipt(&pool, ids.org_id, operator, request) + .await + .unwrap_err(), + "23505", + ); + insert_receipt( + &pool, + other.org_id, + (Some(other.user_id), None, None), + request, + ) + .await + .unwrap(); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn command_receipts_are_not_shared_between_administrators(pool: PgPool) { + let ids = administrator(&pool).await; + let mut request = create("Equipo", 0); + let first = execute(&pool, ids.org_id, ids.user_id, &request) + .await + .unwrap(); + let second_actor = Uuid::now_v7(); + sqlx::query!("INSERT INTO users (id, org_id, email, name) VALUES ($1, $2, 'admin2@example.test', 'Other admin')", second_actor, ids.org_id).execute(&pool).await.unwrap(); + sqlx::query!( + "INSERT INTO person_permission_states (id, org_id, user_id, catalog_version, grants, is_administrator, source) + SELECT $1, org_id, $2, catalog_version, grants, true, 'individual' + FROM person_permission_states WHERE user_id = $3", + Uuid::now_v7(), second_actor, ids.user_id + ).execute(&pool).await.unwrap(); + assert!(matches!( + execute(&pool, ids.org_id, second_actor, &request).await, + Err(TemplateCommandError::Stale) + )); + request.expected_access_revision = 1; + request.action = create("Other", 1).action; + let second = execute(&pool, ids.org_id, second_actor, &request) + .await + .unwrap(); + assert_ne!(first.template_id, second.template_id); + assert_eq!(receipts(&pool, ids.org_id).await.len(), 2); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn unsupported_receipt_version_fails_without_repeating_the_change(pool: PgPool) { + let ids = administrator(&pool).await; + let request = create("Equipo", 0); + execute(&pool, ids.org_id, ids.user_id, &request) + .await + .unwrap(); + sqlx::query!( + "UPDATE permission_change_receipts SET format_version = 2 WHERE org_id = $1", + ids.org_id + ) + .execute(&pool) + .await + .unwrap(); + assert!(matches!( + execute(&pool, ids.org_id, ids.user_id, &request).await, + Err(TemplateCommandError::ReceiptVersion) + )); + assert_eq!(revision_and_count(&pool, ids.org_id).await, (1, 1)); + assert_eq!(receipts(&pool, ids.org_id).await.len(), 1); +}