Add IAM v2 roles/permissions migration and project doc types

Introduce Liquibase changeset 005 for IAM schema v2: CRUD permission codes,
tenant-scoped roles with system tenant_admin, role_permissions by role_id,
user scope fields and user_projects, RLS policies, and full RPC suite for
roles and users. Add core changeset 031 for technical project document types.

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
This commit is contained in:
Cursor Agent 2026-09-08 18:10:45 +00:00
parent 7bcef22d59
commit 25ceb6a6bd
No known key found for this signature in database
4 changed files with 986 additions and 0 deletions

View file

@ -37,5 +37,6 @@
<include file="changesets/028-fix-cost-settings-ambiguous.sql" relativeToChangelogFile="true"/>
<include file="changesets/029-schema-work-program.sql" relativeToChangelogFile="true"/>
<include file="changesets/030-rpc-work-program.sql" relativeToChangelogFile="true"/>
<include file="changesets/031-project-doc-types-technical.sql" relativeToChangelogFile="true"/>
</databaseChangeLog>

View file

@ -0,0 +1,12 @@
--liquibase formatted sql
-- PANELS · core · tipos de documento técnico adicionales
--changeset panel:core-031a-project-doc-types-technical endDelimiter:; splitStatements:true
INSERT INTO core.project_document_types (code, label, required, category) VALUES
('renders', 'Renders', false, 'tecnico'),
('modelo_3d', 'Modelo 3D', false, 'tecnico'),
('sketchup', 'SketchUp', false, 'tecnico')
ON CONFLICT (code) DO NOTHING;
--changeset panel:core-031b-iam-rpc-created-grant endDelimiter:; splitStatements:true
GRANT EXECUTE ON FUNCTION core.rpc_created(jsonb, text, jsonb) TO panels_iam_app;

View file

@ -10,5 +10,6 @@
<include file="changesets/002-rls.sql" relativeToChangelogFile="true"/>
<include file="changesets/003-expenses-warehouse-permissions.sql" relativeToChangelogFile="true"/>
<include file="changesets/004-rpc-users-permissions.sql" relativeToChangelogFile="true"/>
<include file="changesets/005-iam-v2-roles-crud.sql" relativeToChangelogFile="true"/>
</databaseChangeLog>

View file

@ -0,0 +1,972 @@
--liquibase formatted sql
-- PANELS · iam · IAM v2: permisos CRUD, roles por tenant, usuarios ampliados y RPC
--changeset panel:iam-005a-permissions-metadata endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='iam' AND table_name='permissions' AND column_name='module'
ALTER TABLE iam.permissions ADD COLUMN module TEXT;
ALTER TABLE iam.permissions ADD COLUMN verb TEXT;
ALTER TABLE iam.permissions ADD COLUMN perm_group TEXT;
--changeset panel:iam-005b-permissions-v2-seed endDelimiter:; splitStatements:true
INSERT INTO iam.permissions (code, label, module, verb, perm_group) VALUES
('workers.view', 'Consultar padrón', 'workers', 'view', NULL),
('workers.create', 'Alta de personal', 'workers', 'create', NULL),
('workers.update', 'Editar personal', 'workers', 'update', NULL),
('workers.delete', 'Baja de personal', 'workers', 'delete', NULL),
('kanban.view', 'Tablero kanban', 'kanban', 'view', NULL),
('projects.view', 'Consultar obras', 'projects', 'view', NULL),
('projects.create', 'Alta de obras', 'projects', 'create', NULL),
('projects.update', 'Editar obras', 'projects', 'update', NULL),
('companies.view', 'Consultar empresas', 'companies', 'view', NULL),
('companies.create', 'Alta de empresas', 'companies', 'create', NULL),
('companies.update', 'Editar empresas', 'companies', 'update', NULL),
('budget.view', 'Ver presupuesto', 'budget', 'view', NULL),
('budget.create', 'Alta partidas presupuesto', 'budget', 'create', NULL),
('budget.update', 'Editar presupuesto', 'budget', 'update', NULL),
('budget.delete', 'Eliminar partidas', 'budget', 'delete', NULL),
('work_program.view', 'Ver programa de obra', 'work_program', 'view', NULL),
('work_program.create', 'Importar/generar programa', 'work_program', 'create', NULL),
('work_program.update', 'Capturar avances', 'work_program', 'update', NULL),
('cost_control.view', 'Control de costos', 'cost_control', 'view', NULL),
('expenses.view', 'Ver gastos', 'expenses', 'view', NULL),
('expenses.create', 'Capturar gastos', 'expenses', 'create', NULL),
('expenses.update', 'Editar gastos', 'expenses', 'update', NULL),
('expenses.delete', 'Eliminar gastos', 'expenses', 'delete', NULL),
('warehouse.view', 'Ver almacén', 'warehouse', 'view', NULL),
('warehouse.create', 'Entradas almacén', 'warehouse', 'create', NULL),
('warehouse.update', 'Salidas/transferencias', 'warehouse', 'update', NULL),
('payroll.view', 'Ver nómina', 'payroll', 'view', NULL),
('payroll.create', 'Alta nómina/préstamos', 'payroll', 'create', NULL),
('payroll.update', 'Armar/pagar nómina', 'payroll', 'update', NULL),
('payroll.delete', 'Eliminar líneas nómina', 'payroll', 'delete', NULL),
('documents.view', 'Ver documentos/gafetes', 'documents', 'view', NULL),
('documents.create', 'Subir documentos/gafetes', 'documents', 'create', NULL),
('project_docs.tecnico.view', 'Planos y docs técnicos', 'project_docs', 'view', 'tecnico'),
('project_docs.tecnico.create', 'Subir docs técnicos', 'project_docs', 'create', 'tecnico'),
('project_docs.contrato.view', 'Docs contrato', 'project_docs', 'view', 'contrato'),
('project_docs.contrato.create', 'Subir docs contrato', 'project_docs', 'create', 'contrato'),
('project_docs.permisos.view', 'Docs permisos', 'project_docs', 'view', 'permisos'),
('project_docs.permisos.create', 'Subir docs permisos', 'project_docs', 'create', 'permisos'),
('project_docs.ambiental.view', 'Docs ambientales', 'project_docs', 'view', 'ambiental'),
('project_docs.ambiental.create', 'Subir docs ambientales', 'project_docs', 'create', 'ambiental'),
('project_docs.imss.view', 'Docs IMSS', 'project_docs', 'view', 'imss'),
('project_docs.imss.create', 'Subir docs IMSS', 'project_docs', 'create', 'imss'),
('project_docs.sst.view', 'Docs SST', 'project_docs', 'view', 'sst'),
('project_docs.sst.create', 'Subir docs SST', 'project_docs', 'create', 'sst'),
('project_docs.otro.view', 'Otros docs obra', 'project_docs', 'view', 'otro'),
('project_docs.otro.create', 'Subir otros docs', 'project_docs', 'create', 'otro'),
('users.view', 'Listar usuarios IAM', 'users', 'view', NULL),
('users.create', 'Alta usuarios IAM', 'users', 'create', NULL),
('users.update', 'Editar usuarios IAM', 'users', 'update', NULL),
('users.delete', 'Baja usuarios IAM', 'users', 'delete', NULL),
('settings.view', 'Ver configuración', 'settings', 'view', NULL),
('settings.update', 'Guardar configuración', 'settings', 'update', NULL),
('reports.view', 'Reportes e inicio', 'reports', 'view', NULL)
ON CONFLICT (code) DO UPDATE SET
label = EXCLUDED.label,
module = EXCLUDED.module,
verb = EXCLUDED.verb,
perm_group = EXCLUDED.perm_group;
--changeset panel:iam-005c-legacy-perm-map endDelimiter:; splitStatements:true
CREATE TABLE IF NOT EXISTS iam._legacy_perm_map (
legacy_code TEXT NOT NULL,
v2_code TEXT NOT NULL REFERENCES iam.permissions(code) ON DELETE CASCADE,
PRIMARY KEY (legacy_code, v2_code)
);
INSERT INTO iam._legacy_perm_map (legacy_code, v2_code) VALUES
('manage_workers', 'workers.view'),
('manage_workers', 'workers.create'),
('manage_workers', 'workers.update'),
('manage_workers', 'workers.delete'),
('manage_workers', 'kanban.view'),
('manage_projects', 'projects.view'),
('manage_projects', 'projects.create'),
('manage_projects', 'projects.update'),
('manage_companies', 'companies.view'),
('manage_companies', 'companies.create'),
('manage_companies', 'companies.update'),
('manage_budget', 'budget.view'),
('manage_budget', 'budget.create'),
('manage_budget', 'budget.update'),
('manage_budget', 'budget.delete'),
('manage_budget', 'work_program.view'),
('manage_budget', 'work_program.create'),
('manage_budget', 'work_program.update'),
('manage_expenses', 'expenses.view'),
('manage_expenses', 'expenses.create'),
('manage_expenses', 'expenses.update'),
('manage_expenses', 'expenses.delete'),
('manage_expenses', 'cost_control.view'),
('view_expenses', 'expenses.view'),
('view_expenses', 'cost_control.view'),
('manage_payroll', 'payroll.view'),
('manage_payroll', 'payroll.create'),
('manage_payroll', 'payroll.update'),
('manage_payroll', 'payroll.delete'),
('manage_documents', 'documents.view'),
('manage_documents', 'documents.create'),
('manage_documents', 'project_docs.tecnico.view'),
('manage_documents', 'project_docs.tecnico.create'),
('manage_documents', 'project_docs.contrato.view'),
('manage_documents', 'project_docs.contrato.create'),
('manage_documents', 'project_docs.permisos.view'),
('manage_documents', 'project_docs.permisos.create'),
('manage_users', 'users.view'),
('manage_users', 'users.create'),
('manage_users', 'users.update'),
('manage_users', 'users.delete'),
('manage_settings', 'settings.view'),
('manage_settings', 'settings.update'),
('view_reports', 'reports.view'),
('view_warehouse', 'warehouse.view'),
('manage_warehouse', 'warehouse.view'),
('manage_warehouse', 'warehouse.create'),
('manage_warehouse', 'warehouse.update'),
('close_warehouse', 'warehouse.update'),
('manage_cost_settings', 'settings.update'),
('manage_cost_settings', 'cost_control.view')
ON CONFLICT DO NOTHING;
--changeset panel:iam-005d-roles-v2 endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='iam' AND table_name='roles' AND column_name='id'
CREATE TABLE iam.roles_v2 (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id INTEGER,
code TEXT NOT NULL,
label TEXT NOT NULL,
is_system BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO iam.roles_v2 (tenant_id, code, label, is_system)
SELECT NULL, code, label, true
FROM iam.roles
WHERE code = 'tenant_admin';
INSERT INTO iam.roles_v2 (tenant_id, code, label, is_system)
SELECT DISTINCT u.tenant_id, 'operativo', 'Operativo', false
FROM iam.users u
WHERE u.role_code = 'user' AND u.tenant_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM iam.roles_v2 r
WHERE r.tenant_id = u.tenant_id AND r.code = 'operativo'
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_iam_roles_v2_tenant_code
ON iam.roles_v2 (tenant_id, code)
WHERE tenant_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_iam_roles_v2_system_code
ON iam.roles_v2 (code)
WHERE tenant_id IS NULL AND is_system;
--changeset panel:iam-005e-role-permissions-v2 endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='iam' AND table_name='role_permissions' AND column_name='role_id'
CREATE TABLE iam.role_permissions_v2 (
role_id BIGINT NOT NULL REFERENCES iam.roles_v2(id) ON DELETE CASCADE,
permission_code TEXT NOT NULL REFERENCES iam.permissions(code) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_code)
);
INSERT INTO iam.role_permissions_v2 (role_id, permission_code)
SELECT DISTINCT sr.id, lm.v2_code
FROM iam.role_permissions rp
JOIN iam._legacy_perm_map lm ON lm.legacy_code = rp.permission_code
JOIN iam.roles_v2 sr ON sr.code = 'tenant_admin' AND sr.is_system AND sr.tenant_id IS NULL
WHERE rp.role_code = 'tenant_admin'
ON CONFLICT DO NOTHING;
INSERT INTO iam.role_permissions_v2 (role_id, permission_code)
SELECT sr.id, p.code
FROM iam.roles_v2 sr
CROSS JOIN iam.permissions p
WHERE sr.code = 'tenant_admin' AND sr.is_system AND sr.tenant_id IS NULL
AND p.module IS NOT NULL
ON CONFLICT DO NOTHING;
INSERT INTO iam.role_permissions_v2 (role_id, permission_code)
SELECT DISTINCT tr.id, lm.v2_code
FROM iam.role_permissions rp
JOIN iam._legacy_perm_map lm ON lm.legacy_code = rp.permission_code
JOIN iam.roles_v2 tr ON tr.code = 'operativo' AND tr.tenant_id IS NOT NULL
WHERE rp.role_code = 'user'
ON CONFLICT DO NOTHING;
--changeset panel:iam-005f-users-v2-columns endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='iam' AND table_name='users' AND column_name='role_id'
ALTER TABLE iam.users ADD COLUMN role_id BIGINT;
ALTER TABLE iam.users ADD COLUMN status TEXT NOT NULL DEFAULT 'activo';
ALTER TABLE iam.users ADD COLUMN scope_all_projects BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE iam.users ADD COLUMN warehouse_central BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE iam.users ADD COLUMN warehouse_projects BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE iam.users ADD CONSTRAINT users_status_check
CHECK (status IN ('activo', 'baja'));
--changeset panel:iam-005g-users-backfill-role-id endDelimiter:; splitStatements:true
UPDATE iam.users u
SET role_id = sr.id
FROM iam.roles_v2 sr
WHERE u.role_code = 'tenant_admin'
AND sr.code = 'tenant_admin' AND sr.is_system AND sr.tenant_id IS NULL
AND u.role_id IS NULL;
UPDATE iam.users u
SET role_id = sr.id
FROM iam.roles_v2 sr
WHERE u.role_code = 'user'
AND sr.code = 'operativo' AND sr.tenant_id = u.tenant_id
AND u.role_id IS NULL;
UPDATE iam.users u
SET role_id = sr.id
FROM iam.roles_v2 sr
WHERE u.role_id IS NULL
AND sr.code = 'operativo' AND sr.tenant_id = u.tenant_id;
--changeset panel:iam-005h-swap-roles endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='iam' AND table_name='roles' AND column_name='id'
ALTER TABLE iam.users DROP CONSTRAINT IF EXISTS users_role_code_fkey;
ALTER TABLE iam.role_permissions DROP CONSTRAINT IF EXISTS role_permissions_role_code_fkey;
ALTER TABLE iam.role_permissions DROP CONSTRAINT IF EXISTS role_permissions_permission_code_fkey;
DROP TABLE iam.role_permissions;
ALTER TABLE iam.users DROP COLUMN IF EXISTS role_code;
ALTER TABLE iam.role_permissions_v2 RENAME TO role_permissions;
ALTER TABLE iam.roles DROP CONSTRAINT roles_pkey;
DROP TABLE iam.roles;
ALTER TABLE iam.roles_v2 RENAME TO roles;
ALTER TABLE iam.users
ADD CONSTRAINT users_role_id_fkey FOREIGN KEY (role_id) REFERENCES iam.roles(id);
ALTER TABLE iam.users ALTER COLUMN role_id SET NOT NULL;
DO $$
DECLARE
v_admin_role_id bigint;
BEGIN
SELECT id INTO v_admin_role_id
FROM iam.roles
WHERE code = 'tenant_admin' AND is_system AND tenant_id IS NULL
LIMIT 1;
IF v_admin_role_id IS NOT NULL THEN
EXECUTE format(
'CREATE UNIQUE INDEX IF NOT EXISTS uq_iam_users_one_tenant_admin_per_tenant ON iam.users (tenant_id) WHERE role_id = %s',
v_admin_role_id
);
END IF;
END $$;
--changeset panel:iam-005i-user-projects endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='iam' AND table_name='user_projects'
CREATE TABLE iam.user_projects (
user_id BIGINT NOT NULL REFERENCES iam.users(id) ON DELETE CASCADE,
project_id INTEGER NOT NULL,
PRIMARY KEY (user_id, project_id)
);
CREATE INDEX idx_iam_user_projects_user ON iam.user_projects(user_id);
--changeset panel:iam-005j-rls endDelimiter:; splitStatements:true
ALTER TABLE iam.roles ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_and_system_roles ON iam.roles;
CREATE POLICY tenant_and_system_roles ON iam.roles
USING (
(tenant_id IS NOT NULL AND tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
OR (tenant_id IS NULL AND is_system)
)
WITH CHECK (
tenant_id IS NOT NULL
AND tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer
AND NOT is_system
);
ALTER TABLE iam.role_permissions ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_role_permissions ON iam.role_permissions;
CREATE POLICY tenant_role_permissions ON iam.role_permissions
USING (
EXISTS (
SELECT 1 FROM iam.roles r
WHERE r.id = role_permissions.role_id
AND (
(r.tenant_id IS NOT NULL AND r.tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
OR (r.tenant_id IS NULL AND r.is_system)
)
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM iam.roles r
WHERE r.id = role_permissions.role_id
AND r.tenant_id IS NOT NULL
AND r.tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer
AND NOT r.is_system
)
);
ALTER TABLE iam.user_projects ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_user_projects ON iam.user_projects;
CREATE POLICY tenant_user_projects ON iam.user_projects
USING (
EXISTS (
SELECT 1 FROM iam.users u
WHERE u.id = user_projects.user_id
AND u.tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM iam.users u
WHERE u.id = user_projects.user_id
AND u.tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer
)
);
--changeset panel:iam-005k-fn-permission-list splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_permission_list(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_rows jsonb;
BEGIN
SELECT COALESCE(jsonb_agg(
jsonb_build_object(
'code', p.code,
'label', p.label,
'module', p.module,
'verb', p.verb,
'group', p.perm_group
) ORDER BY p.module, p.code
), '[]'::jsonb) INTO v_rows
FROM permissions p
WHERE p.module IS NOT NULL;
RETURN core.rpc_ok(
jsonb_build_object('permissions', v_rows),
format('Catálogo de permisos v2: %s', jsonb_array_length(v_rows)),
jsonb_build_object('fn', 'fn_permission_list')
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_permission_list', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005l-fn-role-list splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_role_list(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_tid integer := COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer
);
v_rows jsonb;
BEGIN
SELECT COALESCE(jsonb_agg(
jsonb_build_object(
'id', r.id,
'code', r.code,
'label', r.label,
'tenant_id', r.tenant_id,
'is_system', r.is_system,
'is_editable', NOT r.is_system,
'created_at', r.created_at
) ORDER BY r.is_system DESC, r.label
), '[]'::jsonb) INTO v_rows
FROM roles r
WHERE (r.tenant_id = v_tid) OR (r.tenant_id IS NULL AND r.is_system);
RETURN core.rpc_ok(
jsonb_build_object('roles', v_rows),
format('Roles del tenant %s: %s', v_tid, jsonb_array_length(v_rows)),
jsonb_build_object('fn', 'fn_role_list', 'tenant_id', v_tid)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_role_list', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005m-fn-role-create splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_role_create(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_tid integer := COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer
);
v_code text := lower(nullif(btrim(payload->>'code'), ''));
v_label text := nullif(btrim(payload->>'label'), '');
v_new_id bigint;
v_role jsonb;
BEGIN
IF v_tid IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_create: tenant_id es obligatorio',
jsonb_build_object('fn', 'fn_role_create'));
END IF;
IF v_code IS NULL OR v_code !~ '^[a-z][a-z0-9_]{1,63}$' THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_create: code inválido (minúsculas, números, _, 2-64 chars)',
jsonb_build_object('fn', 'fn_role_create', 'field', 'code'));
END IF;
IF v_label IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_create: label es obligatorio',
jsonb_build_object('fn', 'fn_role_create', 'field', 'label'));
END IF;
IF v_code IN ('tenant_admin', 'platform_admin', 'user', 'operativo') THEN
RETURN core.rpc_err('VALIDATION', format('fn_role_create: el código %s está reservado', v_code),
jsonb_build_object('fn', 'fn_role_create', 'code', v_code));
END IF;
INSERT INTO roles (tenant_id, code, label, is_system)
VALUES (v_tid, v_code, v_label, false)
RETURNING id INTO v_new_id;
SELECT to_jsonb(r) INTO v_role FROM roles r WHERE r.id = v_new_id;
RETURN core.rpc_created(
jsonb_build_object('role', v_role),
format('Rol %s creado (id=%s)', v_code, v_new_id),
jsonb_build_object('fn', 'fn_role_create', 'role_id', v_new_id, 'tenant_id', v_tid)
);
EXCEPTION
WHEN unique_violation THEN
RETURN core.rpc_err('CONFLICT', format('fn_role_create: ya existe el rol %s en el tenant', v_code),
jsonb_build_object('fn', 'fn_role_create', 'code', v_code, 'tenant_id', v_tid));
WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_role_create', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005n-fn-role-update splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_role_update(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_tid integer := COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer
);
v_role_id bigint := NULLIF(payload->>'role_id', '')::bigint;
v_label text := nullif(btrim(payload->>'label'), '');
v_role record;
v_updated jsonb;
BEGIN
IF v_role_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_update: role_id es obligatorio',
jsonb_build_object('fn', 'fn_role_update', 'field', 'role_id'));
END IF;
SELECT * INTO v_role FROM roles WHERE id = v_role_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_role_update: rol id=%s no encontrado', v_role_id),
jsonb_build_object('fn', 'fn_role_update', 'role_id', v_role_id));
END IF;
IF v_role.is_system OR v_role.tenant_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_update: no se puede modificar un rol del sistema',
jsonb_build_object('fn', 'fn_role_update', 'role_id', v_role_id));
END IF;
IF v_role.tenant_id <> v_tid THEN
RETURN core.rpc_err('VALIDATION', format('fn_role_update: rol id=%s no pertenece al tenant %s', v_role_id, v_tid),
jsonb_build_object('fn', 'fn_role_update', 'role_id', v_role_id, 'tenant_id', v_tid));
END IF;
IF v_label IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_update: label es obligatorio',
jsonb_build_object('fn', 'fn_role_update', 'field', 'label'));
END IF;
UPDATE roles SET label = v_label WHERE id = v_role_id;
SELECT to_jsonb(r) INTO v_updated FROM roles r WHERE r.id = v_role_id;
RETURN core.rpc_ok(
jsonb_build_object('role', v_updated),
format('Rol %s actualizado (id=%s)', v_updated->>'code', v_role_id),
jsonb_build_object('fn', 'fn_role_update', 'role_id', v_role_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_role_update', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005o-fn-role-delete splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_role_delete(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_tid integer := COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer
);
v_role_id bigint := NULLIF(payload->>'role_id', '')::bigint;
v_role record;
v_assigned integer;
BEGIN
IF v_role_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_delete: role_id es obligatorio',
jsonb_build_object('fn', 'fn_role_delete', 'field', 'role_id'));
END IF;
SELECT * INTO v_role FROM roles WHERE id = v_role_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_role_delete: rol id=%s no encontrado', v_role_id),
jsonb_build_object('fn', 'fn_role_delete', 'role_id', v_role_id));
END IF;
IF v_role.is_system OR v_role.tenant_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_delete: no se puede eliminar un rol del sistema',
jsonb_build_object('fn', 'fn_role_delete', 'role_id', v_role_id));
END IF;
IF v_role.tenant_id <> v_tid THEN
RETURN core.rpc_err('VALIDATION', format('fn_role_delete: rol id=%s no pertenece al tenant %s', v_role_id, v_tid),
jsonb_build_object('fn', 'fn_role_delete', 'role_id', v_role_id, 'tenant_id', v_tid));
END IF;
SELECT COUNT(*)::integer INTO v_assigned FROM users WHERE role_id = v_role_id;
IF v_assigned > 0 THEN
RETURN core.rpc_err('CONFLICT',
format('fn_role_delete: rol %s tiene %s usuario(s) asignado(s)', v_role.code, v_assigned),
jsonb_build_object('fn', 'fn_role_delete', 'role_id', v_role_id, 'assigned_users', v_assigned));
END IF;
DELETE FROM roles WHERE id = v_role_id;
RETURN core.rpc_ok(
jsonb_build_object('role_id', v_role_id, 'code', v_role.code),
format('Rol %s eliminado (id=%s)', v_role.code, v_role_id),
jsonb_build_object('fn', 'fn_role_delete', 'role_id', v_role_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_role_delete', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005p-fn-role-permissions-get splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_role_permissions_get(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_role_id bigint := NULLIF(payload->>'role_id', '')::bigint;
v_role record;
v_rows jsonb;
BEGIN
IF v_role_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_permissions_get: role_id es obligatorio',
jsonb_build_object('fn', 'fn_role_permissions_get', 'field', 'role_id'));
END IF;
SELECT * INTO v_role FROM roles WHERE id = v_role_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_role_permissions_get: rol id=%s no encontrado', v_role_id),
jsonb_build_object('fn', 'fn_role_permissions_get', 'role_id', v_role_id));
END IF;
SELECT COALESCE(jsonb_agg(rp.permission_code ORDER BY rp.permission_code), '[]'::jsonb) INTO v_rows
FROM role_permissions rp WHERE rp.role_id = v_role_id;
RETURN core.rpc_ok(
jsonb_build_object(
'role_id', v_role_id,
'role_code', v_role.code,
'is_editable', NOT v_role.is_system,
'permissions', v_rows
),
format('Permisos del rol %s: %s', v_role.code, jsonb_array_length(v_rows)),
jsonb_build_object('fn', 'fn_role_permissions_get', 'role_id', v_role_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_role_permissions_get', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005q-fn-role-permissions-save splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_role_permissions_save(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_role_id bigint := NULLIF(payload->>'role_id', '')::bigint;
v_perms jsonb := COALESCE(payload->'permissions', '[]'::jsonb);
v_role record;
v_perm text;
BEGIN
IF v_role_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_permissions_save: role_id es obligatorio',
jsonb_build_object('fn', 'fn_role_permissions_save', 'field', 'role_id'));
END IF;
SELECT * INTO v_role FROM roles WHERE id = v_role_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_role_permissions_save: rol id=%s no encontrado', v_role_id),
jsonb_build_object('fn', 'fn_role_permissions_save', 'role_id', v_role_id));
END IF;
IF v_role.is_system AND v_role.code = 'tenant_admin' THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_permissions_save: no se puede modificar tenant_admin',
jsonb_build_object('fn', 'fn_role_permissions_save', 'role_id', v_role_id));
END IF;
IF v_role.is_system OR v_role.tenant_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_permissions_save: no se puede modificar un rol del sistema',
jsonb_build_object('fn', 'fn_role_permissions_save', 'role_id', v_role_id));
END IF;
DELETE FROM role_permissions WHERE role_id = v_role_id;
FOR v_perm IN SELECT jsonb_array_elements_text(v_perms)
LOOP
INSERT INTO role_permissions (role_id, permission_code)
VALUES (v_role_id, v_perm)
ON CONFLICT DO NOTHING;
END LOOP;
RETURN core.rpc_ok(
jsonb_build_object('role_id', v_role_id, 'role_code', v_role.code, 'permissions', v_perms),
format('Permisos del rol %s actualizados (%s permiso(s))', v_role.code, jsonb_array_length(v_perms)),
jsonb_build_object('fn', 'fn_role_permissions_save', 'role_id', v_role_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_role_permissions_save', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005r-fn-user-list splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_user_list(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_tid integer := COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer
);
v_rows jsonb;
BEGIN
SELECT COALESCE(jsonb_agg(row_data ORDER BY row_data->>'display_name'), '[]'::jsonb) INTO v_rows
FROM (
SELECT jsonb_build_object(
'id', u.id,
'username', u.username,
'display_name', u.display_name,
'email', u.email,
'company_id', u.company_id,
'tenant_id', u.tenant_id,
'role_id', u.role_id,
'role_code', r.code,
'role_label', r.label,
'status', u.status,
'scope_all_projects', u.scope_all_projects,
'warehouse_central', u.warehouse_central,
'warehouse_projects', u.warehouse_projects,
'project_ids', COALESCE((
SELECT jsonb_agg(up.project_id ORDER BY up.project_id)
FROM user_projects up WHERE up.user_id = u.id
), '[]'::jsonb),
'is_owner', (r.code = 'tenant_admin' AND r.is_system AND r.tenant_id IS NULL),
'must_change_password', u.must_change_password,
'created_at', u.created_at
) AS row_data
FROM users u
JOIN roles r ON r.id = u.role_id
WHERE u.tenant_id = v_tid
) sub;
RETURN core.rpc_ok(
jsonb_build_object('users', v_rows),
format('Usuarios del tenant %s: %s cuenta(s)', v_tid, jsonb_array_length(v_rows)),
jsonb_build_object('fn', 'fn_user_list', 'tenant_id', v_tid)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_user_list', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005s-fn-user-create splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_user_create(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_tid integer := COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer
);
v_username text := lower(nullif(btrim(payload->>'username'), ''));
v_password_hash text := nullif(btrim(payload->>'password_hash'), '');
v_display_name text := nullif(btrim(payload->>'display_name'), '');
v_email text := COALESCE(nullif(btrim(payload->>'email'), ''), '');
v_role_id bigint := NULLIF(payload->>'role_id', '')::bigint;
v_company_id integer := NULLIF(payload->>'company_id', '')::integer;
v_status text := COALESCE(nullif(btrim(payload->>'status'), ''), 'activo');
v_scope_all boolean := COALESCE((payload->>'scope_all_projects')::boolean, false);
v_wh_central boolean := COALESCE((payload->>'warehouse_central')::boolean, false);
v_wh_projects boolean := COALESCE((payload->>'warehouse_projects')::boolean, true);
v_role record;
v_new_id bigint;
v_user jsonb;
BEGIN
IF v_tid IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_create: tenant_id es obligatorio',
jsonb_build_object('fn', 'fn_user_create'));
END IF;
IF v_username IS NULL OR v_username !~ '^[a-z0-9._-]{3,64}$' THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_create: username inválido',
jsonb_build_object('fn', 'fn_user_create', 'field', 'username'));
END IF;
IF v_password_hash IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_create: password_hash es obligatorio',
jsonb_build_object('fn', 'fn_user_create', 'field', 'password_hash'));
END IF;
IF v_display_name IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_create: display_name es obligatorio',
jsonb_build_object('fn', 'fn_user_create', 'field', 'display_name'));
END IF;
IF v_role_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_create: role_id es obligatorio',
jsonb_build_object('fn', 'fn_user_create', 'field', 'role_id'));
END IF;
IF v_status NOT IN ('activo', 'baja') THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_create: status debe ser activo o baja',
jsonb_build_object('fn', 'fn_user_create', 'field', 'status'));
END IF;
SELECT * INTO v_role FROM roles WHERE id = v_role_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_user_create: rol id=%s no encontrado', v_role_id),
jsonb_build_object('fn', 'fn_user_create', 'role_id', v_role_id));
END IF;
IF v_role.code = 'tenant_admin' AND v_role.is_system THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_create: use bootstrap para crear tenant_admin',
jsonb_build_object('fn', 'fn_user_create', 'role_id', v_role_id));
END IF;
IF v_role.tenant_id IS NOT NULL AND v_role.tenant_id <> v_tid THEN
RETURN core.rpc_err('VALIDATION', format('fn_user_create: rol id=%s no pertenece al tenant %s', v_role_id, v_tid),
jsonb_build_object('fn', 'fn_user_create', 'role_id', v_role_id));
END IF;
INSERT INTO users (
username, password_hash, display_name, email, company_id, tenant_id, role_id,
status, scope_all_projects, warehouse_central, warehouse_projects, must_change_password
) VALUES (
v_username, v_password_hash, v_display_name, v_email, v_company_id, v_tid, v_role_id,
v_status, v_scope_all, v_wh_central, v_wh_projects, COALESCE((payload->>'must_change_password')::boolean, true)
)
RETURNING id INTO v_new_id;
SELECT to_jsonb(u) INTO v_user FROM users u WHERE u.id = v_new_id;
RETURN core.rpc_created(
jsonb_build_object('user', v_user),
format('Usuario %s creado (id=%s)', v_username, v_new_id),
jsonb_build_object('fn', 'fn_user_create', 'user_id', v_new_id, 'tenant_id', v_tid)
);
EXCEPTION
WHEN unique_violation THEN
RETURN core.rpc_err('CONFLICT', format('fn_user_create: ya existe el username %s', v_username),
jsonb_build_object('fn', 'fn_user_create', 'username', v_username));
WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_user_create', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005t-fn-user-update splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_user_update(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_tid integer := COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer
);
v_user_id bigint := NULLIF(payload->>'id', '')::bigint;
v_user record;
v_role record;
v_role_id bigint := NULLIF(payload->>'role_id', '')::bigint;
v_updated jsonb;
BEGIN
IF v_user_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_update: id es obligatorio',
jsonb_build_object('fn', 'fn_user_update', 'field', 'id'));
END IF;
SELECT u.*, r.code AS role_code, r.is_system AS role_is_system, r.tenant_id AS role_tenant_id
INTO v_user
FROM users u
JOIN roles r ON r.id = u.role_id
WHERE u.id = v_user_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_user_update: usuario id=%s no encontrado', v_user_id),
jsonb_build_object('fn', 'fn_user_update', 'user_id', v_user_id));
END IF;
IF v_user.tenant_id <> v_tid THEN
RETURN core.rpc_err('VALIDATION', format('fn_user_update: usuario id=%s no pertenece al tenant %s', v_user_id, v_tid),
jsonb_build_object('fn', 'fn_user_update', 'user_id', v_user_id));
END IF;
IF v_user.role_code = 'tenant_admin' AND v_user.role_is_system AND v_user.role_tenant_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_update: no se puede modificar el propietario del tenant',
jsonb_build_object('fn', 'fn_user_update', 'user_id', v_user_id));
END IF;
IF v_role_id IS NOT NULL THEN
SELECT * INTO v_role FROM roles WHERE id = v_role_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_user_update: rol id=%s no encontrado', v_role_id),
jsonb_build_object('fn', 'fn_user_update', 'role_id', v_role_id));
END IF;
IF v_role.code = 'tenant_admin' AND v_role.is_system THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_update: no se puede asignar rol tenant_admin',
jsonb_build_object('fn', 'fn_user_update', 'role_id', v_role_id));
END IF;
IF v_role.tenant_id IS NOT NULL AND v_role.tenant_id <> v_tid THEN
RETURN core.rpc_err('VALIDATION', format('fn_user_update: rol id=%s no pertenece al tenant %s', v_role_id, v_tid),
jsonb_build_object('fn', 'fn_user_update', 'role_id', v_role_id));
END IF;
END IF;
UPDATE users SET
display_name = COALESCE(nullif(btrim(payload->>'display_name'), ''), display_name),
email = COALESCE(nullif(btrim(payload->>'email'), ''), email),
company_id = COALESCE(NULLIF(payload->>'company_id', '')::integer, company_id),
role_id = COALESCE(v_role_id, role_id),
status = COALESCE(nullif(btrim(payload->>'status'), ''), status),
scope_all_projects = COALESCE((payload->>'scope_all_projects')::boolean, scope_all_projects),
warehouse_central = COALESCE((payload->>'warehouse_central')::boolean, warehouse_central),
warehouse_projects = COALESCE((payload->>'warehouse_projects')::boolean, warehouse_projects)
WHERE id = v_user_id;
SELECT to_jsonb(u) INTO v_updated FROM users u WHERE u.id = v_user_id;
RETURN core.rpc_ok(
jsonb_build_object('user', v_updated),
format('Usuario id=%s actualizado', v_user_id),
jsonb_build_object('fn', 'fn_user_update', 'user_id', v_user_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_user_update', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005u-fn-user-reset-access splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_user_reset_access(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_tid integer := COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer
);
v_user_id bigint := NULLIF(payload->>'user_id', '')::bigint;
v_password_hash text := nullif(btrim(payload->>'password_hash'), '');
v_user record;
BEGIN
IF v_user_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_reset_access: user_id es obligatorio',
jsonb_build_object('fn', 'fn_user_reset_access', 'field', 'user_id'));
END IF;
IF v_password_hash IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_reset_access: password_hash es obligatorio',
jsonb_build_object('fn', 'fn_user_reset_access', 'field', 'password_hash'));
END IF;
SELECT u.*, r.code AS role_code, r.is_system AS role_is_system, r.tenant_id AS role_tenant_id
INTO v_user
FROM users u
JOIN roles r ON r.id = u.role_id
WHERE u.id = v_user_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_user_reset_access: usuario id=%s no encontrado', v_user_id),
jsonb_build_object('fn', 'fn_user_reset_access', 'user_id', v_user_id));
END IF;
IF v_user.tenant_id <> v_tid THEN
RETURN core.rpc_err('VALIDATION', format('fn_user_reset_access: usuario id=%s no pertenece al tenant %s', v_user_id, v_tid),
jsonb_build_object('fn', 'fn_user_reset_access', 'user_id', v_user_id));
END IF;
UPDATE users
SET password_hash = v_password_hash,
must_change_password = COALESCE((payload->>'must_change_password')::boolean, true)
WHERE id = v_user_id;
RETURN core.rpc_ok(
jsonb_build_object('user_id', v_user_id),
format('Acceso restablecido para usuario id=%s', v_user_id),
jsonb_build_object('fn', 'fn_user_reset_access', 'user_id', v_user_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_user_reset_access', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005v-fn-user-projects-save splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_user_projects_save(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_tid integer := COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer
);
v_user_id bigint := NULLIF(payload->>'user_id', '')::bigint;
v_project_ids jsonb := COALESCE(payload->'project_ids', '[]'::jsonb);
v_user record;
v_pid integer;
BEGIN
IF v_user_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_projects_save: user_id es obligatorio',
jsonb_build_object('fn', 'fn_user_projects_save', 'field', 'user_id'));
END IF;
SELECT u.*, r.code AS role_code, r.is_system AS role_is_system, r.tenant_id AS role_tenant_id
INTO v_user
FROM users u
JOIN roles r ON r.id = u.role_id
WHERE u.id = v_user_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_user_projects_save: usuario id=%s no encontrado', v_user_id),
jsonb_build_object('fn', 'fn_user_projects_save', 'user_id', v_user_id));
END IF;
IF v_user.tenant_id <> v_tid THEN
RETURN core.rpc_err('VALIDATION', format('fn_user_projects_save: usuario id=%s no pertenece al tenant %s', v_user_id, v_tid),
jsonb_build_object('fn', 'fn_user_projects_save', 'user_id', v_user_id));
END IF;
IF v_user.role_code = 'tenant_admin' AND v_user.role_is_system AND v_user.role_tenant_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_projects_save: no se puede modificar proyectos del propietario',
jsonb_build_object('fn', 'fn_user_projects_save', 'user_id', v_user_id));
END IF;
DELETE FROM user_projects WHERE user_id = v_user_id;
FOR v_pid IN
SELECT DISTINCT (elem)::integer
FROM jsonb_array_elements_text(v_project_ids) AS elem
WHERE btrim(elem) ~ '^\d+$'
LOOP
INSERT INTO user_projects (user_id, project_id) VALUES (v_user_id, v_pid);
END LOOP;
RETURN core.rpc_ok(
jsonb_build_object('user_id', v_user_id, 'project_ids', v_project_ids),
format('Proyectos del usuario id=%s actualizados (%s)', v_user_id, jsonb_array_length(v_project_ids)),
jsonb_build_object('fn', 'fn_user_projects_save', 'user_id', v_user_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_user_projects_save', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005w-iam-v2-rpc-grants endDelimiter:; splitStatements:true
GRANT EXECUTE ON FUNCTION iam.fn_permission_list(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_role_list(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_role_create(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_role_update(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_role_delete(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_role_permissions_get(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_role_permissions_save(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_user_list(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_user_create(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_user_update(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_user_reset_access(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_user_projects_save(jsonb) TO panels_iam_app;