-- ============================================================================ -- RBAC Schema: User Roles Table -- Assigns roles to users within a tenant -- ============================================================================ -- ============================================================================ -- USER_ROLES TABLE -- Maps users to their assigned roles -- ============================================================================ CREATE TABLE IF NOT EXISTS rbac.user_roles ( -- Primary key id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- User reference user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE, -- Role reference role_id UUID NOT NULL REFERENCES rbac.roles(id) ON DELETE CASCADE, -- Tenant (denormalized for RLS efficiency) tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, -- Assignment metadata is_primary BOOLEAN NOT NULL DEFAULT false, assigned_reason TEXT, -- Validity period (optional for temporary roles) valid_from TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, valid_until TIMESTAMPTZ DEFAULT NULL, -- Status is_active BOOLEAN NOT NULL DEFAULT true, -- Audit fields created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, assigned_by UUID REFERENCES users.users(id), revoked_at TIMESTAMPTZ DEFAULT NULL, revoked_by UUID REFERENCES users.users(id), -- Constraints CONSTRAINT uq_user_role UNIQUE (user_id, role_id), CONSTRAINT chk_valid_period CHECK (valid_until IS NULL OR valid_until > valid_from) ); -- ============================================================================ -- INDEXES -- ============================================================================ CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON rbac.user_roles(user_id); CREATE INDEX IF NOT EXISTS idx_user_roles_role_id ON rbac.user_roles(role_id); CREATE INDEX IF NOT EXISTS idx_user_roles_tenant_id ON rbac.user_roles(tenant_id); CREATE INDEX IF NOT EXISTS idx_user_roles_is_active ON rbac.user_roles(is_active); CREATE INDEX IF NOT EXISTS idx_user_roles_is_primary ON rbac.user_roles(user_id, is_primary) WHERE is_primary = true; CREATE INDEX IF NOT EXISTS idx_user_roles_validity ON rbac.user_roles(valid_from, valid_until) WHERE is_active = true; -- ============================================================================ -- ROW LEVEL SECURITY -- ============================================================================ ALTER TABLE rbac.user_roles ENABLE ROW LEVEL SECURITY; -- Policy: Users can only see role assignments in their tenant CREATE POLICY user_roles_tenant_isolation ON rbac.user_roles FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); -- ============================================================================ -- TRIGGERS -- ============================================================================ -- Auto-update updated_at timestamp CREATE OR REPLACE FUNCTION rbac.update_user_roles_timestamp() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = CURRENT_TIMESTAMP; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_user_roles_updated_at BEFORE UPDATE ON rbac.user_roles FOR EACH ROW EXECUTE FUNCTION rbac.update_user_roles_timestamp(); -- Ensure only one primary role per user CREATE OR REPLACE FUNCTION rbac.ensure_single_primary_role() RETURNS TRIGGER AS $$ BEGIN IF NEW.is_primary = true THEN UPDATE rbac.user_roles SET is_primary = false WHERE user_id = NEW.user_id AND id != NEW.id AND is_primary = true; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_single_primary_role BEFORE INSERT OR UPDATE ON rbac.user_roles FOR EACH ROW WHEN (NEW.is_primary = true) EXECUTE FUNCTION rbac.ensure_single_primary_role(); -- ============================================================================ -- VIEW: User with roles and permissions -- ============================================================================ CREATE OR REPLACE VIEW rbac.v_user_permissions AS SELECT DISTINCT ur.user_id, ur.tenant_id, u.email, u.display_name, r.id AS role_id, r.name AS role_name, r.slug AS role_slug, r.hierarchy_level, ur.is_primary, p.code AS permission_code, p.module, p.action, p.resource, rp.grant_type FROM rbac.user_roles ur JOIN users.users u ON ur.user_id = u.id JOIN rbac.roles r ON ur.role_id = r.id JOIN rbac.role_permissions rp ON r.id = rp.role_id JOIN rbac.permissions p ON rp.permission_id = p.id WHERE ur.is_active = true AND r.is_active = true AND p.is_active = true AND (ur.valid_until IS NULL OR ur.valid_until > CURRENT_TIMESTAMP) AND ur.valid_from <= CURRENT_TIMESTAMP; -- ============================================================================ -- FUNCTION: Check if user has permission -- ============================================================================ CREATE OR REPLACE FUNCTION rbac.user_has_permission( p_user_id UUID, p_tenant_id UUID, p_permission_code VARCHAR(100) ) RETURNS BOOLEAN AS $$ DECLARE v_has_allow BOOLEAN; v_has_deny BOOLEAN; BEGIN -- Check for explicit deny first SELECT EXISTS ( SELECT 1 FROM rbac.v_user_permissions WHERE user_id = p_user_id AND tenant_id = p_tenant_id AND permission_code = p_permission_code AND grant_type = 'deny' ) INTO v_has_deny; IF v_has_deny THEN RETURN false; END IF; -- Check for allow SELECT EXISTS ( SELECT 1 FROM rbac.v_user_permissions WHERE user_id = p_user_id AND tenant_id = p_tenant_id AND permission_code = p_permission_code AND grant_type = 'allow' ) INTO v_has_allow; RETURN v_has_allow; END; $$ LANGUAGE plpgsql STABLE; -- ============================================================================ -- FUNCTION: Get user's effective permissions -- ============================================================================ CREATE OR REPLACE FUNCTION rbac.get_user_permissions( p_user_id UUID, p_tenant_id UUID ) RETURNS TABLE ( permission_code VARCHAR(100), permission_name VARCHAR(200), module VARCHAR(50), action VARCHAR(20), resource VARCHAR(100) ) AS $$ BEGIN RETURN QUERY SELECT DISTINCT p.code, p.name, p.module, p.action, p.resource FROM rbac.user_roles ur JOIN rbac.roles r ON ur.role_id = r.id JOIN rbac.role_permissions rp ON r.id = rp.role_id JOIN rbac.permissions p ON rp.permission_id = p.id WHERE ur.user_id = p_user_id AND ur.tenant_id = p_tenant_id AND ur.is_active = true AND r.is_active = true AND p.is_active = true AND rp.grant_type = 'allow' AND (ur.valid_until IS NULL OR ur.valid_until > CURRENT_TIMESTAMP) AND ur.valid_from <= CURRENT_TIMESTAMP AND NOT EXISTS ( -- Exclude denied permissions SELECT 1 FROM rbac.user_roles ur2 JOIN rbac.role_permissions rp2 ON ur2.role_id = rp2.role_id WHERE ur2.user_id = p_user_id AND ur2.tenant_id = p_tenant_id AND rp2.permission_id = p.id AND rp2.grant_type = 'deny' AND ur2.is_active = true ) ORDER BY p.module, p.code; END; $$ LANGUAGE plpgsql STABLE; -- ============================================================================ -- FUNCTION: Assign role to user -- ============================================================================ CREATE OR REPLACE FUNCTION rbac.assign_role_to_user( p_user_id UUID, p_role_id UUID, p_tenant_id UUID, p_assigned_by UUID, p_is_primary BOOLEAN DEFAULT false, p_valid_until TIMESTAMPTZ DEFAULT NULL ) RETURNS UUID AS $$ DECLARE v_assignment_id UUID; BEGIN INSERT INTO rbac.user_roles ( user_id, role_id, tenant_id, is_primary, valid_until, assigned_by ) VALUES ( p_user_id, p_role_id, p_tenant_id, p_is_primary, p_valid_until, p_assigned_by ) ON CONFLICT (user_id, role_id) DO UPDATE SET is_active = true, is_primary = EXCLUDED.is_primary, valid_until = EXCLUDED.valid_until, assigned_by = EXCLUDED.assigned_by, revoked_at = NULL, revoked_by = NULL, updated_at = CURRENT_TIMESTAMP RETURNING id INTO v_assignment_id; RETURN v_assignment_id; END; $$ LANGUAGE plpgsql; -- ============================================================================ -- GRANTS -- ============================================================================ GRANT SELECT, INSERT, UPDATE, DELETE ON rbac.user_roles TO trading_user; GRANT SELECT ON rbac.v_user_permissions TO trading_user; GRANT EXECUTE ON FUNCTION rbac.user_has_permission TO trading_user; GRANT EXECUTE ON FUNCTION rbac.get_user_permissions TO trading_user; GRANT EXECUTE ON FUNCTION rbac.assign_role_to_user TO trading_user; -- ============================================================================ -- COMMENTS -- ============================================================================ COMMENT ON TABLE rbac.user_roles IS 'Maps users to their assigned roles within a tenant'; COMMENT ON COLUMN rbac.user_roles.is_primary IS 'Primary role shown in UI, user can have multiple roles'; COMMENT ON COLUMN rbac.user_roles.valid_from IS 'When the role assignment becomes active'; COMMENT ON COLUMN rbac.user_roles.valid_until IS 'When the role assignment expires (NULL = never)'; COMMENT ON VIEW rbac.v_user_permissions IS 'Flattened view of users with their effective permissions'; COMMENT ON FUNCTION rbac.user_has_permission IS 'Check if a user has a specific permission'; COMMENT ON FUNCTION rbac.get_user_permissions IS 'Get all effective permissions for a user';