feat(equipment): schema + RLS + permission (data layer)
Migration 033 adds equipment (registry; status, optional required_certification_id, is_active archive) and equipment_reservations (member time-window reservation; denormalized space_id). RLS is additive and default-deny: equipment readable by managers (all) or members (active only), reservations by managers (all) or the member (own) with manager-only UPDATE and no INSERT/DELETE policy so reserve/cancel funnels through a validated service-client action (status, no-overlap and required-certification enforced there, with manager override). New equipment.manage permission (group Equipment) seeded to board and backfilled. Mirrored idempotently in schema.sql; types and schema docs updated.
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
-- =============================================================================
|
||||
-- 033: Equipment registry + reservations
|
||||
-- =============================================================================
|
||||
-- Two tables:
|
||||
--
|
||||
-- equipment a per-space tool/equipment record: name,
|
||||
-- description, location, operational status
|
||||
-- (available/maintenance/retired), optional
|
||||
-- required_certification_id (reserving a cert-gated
|
||||
-- tool requires the member hold that certification),
|
||||
-- asset_tag, is_active archive flag.
|
||||
-- equipment_reservations a member's time-window reservation of one piece of
|
||||
-- equipment (reserved/cancelled/completed).
|
||||
-- space_id is denormalized for RLS without a join.
|
||||
-- Active reservations for the same equipment may not
|
||||
-- overlap (enforced in the validated action).
|
||||
--
|
||||
-- One new permission code (lib/permissions-catalog.ts, group "Equipment"):
|
||||
--
|
||||
-- equipment.manage registry CRUD, status/maintenance, and adjusting or
|
||||
-- cancelling any reservation (the override).
|
||||
--
|
||||
-- Reserving needs no permission, only space membership: it is gated by the
|
||||
-- equipment being available and, when set, by the member holding the
|
||||
-- required certification (manager override allowed). The cert check uses the
|
||||
-- normal certifications data; no service-role bypass of that surface.
|
||||
--
|
||||
-- RLS posture (additive, default-deny; the guarded surface):
|
||||
--
|
||||
-- * equipment SELECT: equipment.manage holders see all; ordinary members
|
||||
-- see only is_active (mirrors classes_select). Writes: equipment.manage.
|
||||
-- * equipment_reservations SELECT: equipment.manage (all in space) or the
|
||||
-- member (own). UPDATE: equipment.manage. NO INSERT/DELETE policy:
|
||||
-- reserve/cancel funnels through one validated service-client action
|
||||
-- that enforces status, the no-overlap rule and the required-cert gate
|
||||
-- (same pattern as class_signups / form_submissions). No anonymous path.
|
||||
--
|
||||
-- Extends seed_default_role_permissions() so new spaces seed the code to
|
||||
-- board, and backfills board -> equipment.manage for existing spaces.
|
||||
--
|
||||
-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS, DROP POLICY IF EXISTS then
|
||||
-- CREATE, CREATE OR REPLACE FUNCTION, ON CONFLICT DO NOTHING backfill.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.equipment (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
||||
name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 200),
|
||||
description text,
|
||||
location text,
|
||||
status text NOT NULL DEFAULT 'available'
|
||||
CHECK (status IN ('available','maintenance','retired')),
|
||||
required_certification_id uuid REFERENCES public.certifications(id) ON DELETE SET NULL,
|
||||
asset_tag text,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_by uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_equipment_space ON public.equipment (space_id, is_active);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.equipment_reservations (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
equipment_id uuid NOT NULL REFERENCES public.equipment(id) ON DELETE CASCADE,
|
||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
||||
member_id uuid NOT NULL REFERENCES public.space_members(id) ON DELETE CASCADE,
|
||||
starts_at timestamptz NOT NULL,
|
||||
ends_at timestamptz NOT NULL CHECK (ends_at > starts_at),
|
||||
status text NOT NULL DEFAULT 'reserved'
|
||||
CHECK (status IN ('reserved','cancelled','completed')),
|
||||
notes text,
|
||||
created_by uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_equipment_res_space ON public.equipment_reservations (space_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_equipment_res_equipment ON public.equipment_reservations (equipment_id, starts_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_equipment_res_member ON public.equipment_reservations (member_id);
|
||||
|
||||
ALTER TABLE public.equipment ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.equipment_reservations ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS equipment_select ON public.equipment;
|
||||
DROP POLICY IF EXISTS equipment_insert ON public.equipment;
|
||||
DROP POLICY IF EXISTS equipment_update ON public.equipment;
|
||||
DROP POLICY IF EXISTS equipment_delete ON public.equipment;
|
||||
CREATE POLICY equipment_select ON public.equipment FOR SELECT
|
||||
USING (
|
||||
public.user_has_permission(auth.uid(), space_id, 'equipment.manage')
|
||||
OR (
|
||||
space_id IN (SELECT public.get_user_space_ids(auth.uid()))
|
||||
AND is_active
|
||||
)
|
||||
);
|
||||
CREATE POLICY equipment_insert ON public.equipment FOR INSERT
|
||||
WITH CHECK (public.user_has_permission(auth.uid(), space_id, 'equipment.manage'));
|
||||
CREATE POLICY equipment_update ON public.equipment FOR UPDATE
|
||||
USING (public.user_has_permission(auth.uid(), space_id, 'equipment.manage'));
|
||||
CREATE POLICY equipment_delete ON public.equipment FOR DELETE
|
||||
USING (public.user_has_permission(auth.uid(), space_id, 'equipment.manage'));
|
||||
|
||||
DROP POLICY IF EXISTS equipment_reservations_select ON public.equipment_reservations;
|
||||
DROP POLICY IF EXISTS equipment_reservations_update ON public.equipment_reservations;
|
||||
CREATE POLICY equipment_reservations_select ON public.equipment_reservations FOR SELECT
|
||||
USING (
|
||||
public.user_has_permission(auth.uid(), space_id, 'equipment.manage')
|
||||
OR member_id IN (
|
||||
SELECT id FROM public.space_members
|
||||
WHERE user_id = auth.uid() AND space_id = equipment_reservations.space_id
|
||||
)
|
||||
);
|
||||
CREATE POLICY equipment_reservations_update ON public.equipment_reservations FOR UPDATE
|
||||
USING (public.user_has_permission(auth.uid(), space_id, 'equipment.manage'));
|
||||
-- No INSERT/DELETE policy: reserve/cancel via a validated service-client action.
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_equipment_touch ON public.equipment;
|
||||
CREATE TRIGGER trg_equipment_touch
|
||||
BEFORE UPDATE ON public.equipment
|
||||
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.seed_default_role_permissions()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.space_role_permissions (space_id, subject, permission)
|
||||
SELECT NEW.id, d.subject, d.permission
|
||||
FROM (VALUES
|
||||
('board','ops.kb.read'),('board','ops.kb.write'),('board','ops.process.read'),
|
||||
('board','ops.process.write'),('board','ops.secrets.read'),('board','ops.secrets.write'),
|
||||
('board','ops.arealeads.manage'),('board','members.manage'),('board','payments.manage'),
|
||||
('board','governance.manage'),('board','forum.moderate'),('board','forms.manage'),
|
||||
('board','certifications.manage'),('board','certifications.grant'),
|
||||
('board','classes.manage'),('board','classes.instruct'),
|
||||
('board','equipment.manage'),
|
||||
('board','customize.manage'),('board','settings.manage'),
|
||||
('treasurer','payments.manage'),('treasurer','ops.kb.read'),('treasurer','ops.process.read'),
|
||||
('member','ops.kb.read'),('member','ops.process.read'),
|
||||
('associate','ops.kb.read')
|
||||
) AS d(subject, permission)
|
||||
ON CONFLICT (space_id, subject, permission) DO NOTHING;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
INSERT INTO public.space_role_permissions (space_id, subject, permission)
|
||||
SELECT id, 'board', 'equipment.manage' FROM public.spaces
|
||||
ON CONFLICT (space_id, subject, permission) DO NOTHING;
|
||||
@@ -1829,6 +1829,7 @@ BEGIN
|
||||
('board','governance.manage'),('board','forum.moderate'),('board','forms.manage'),
|
||||
('board','certifications.manage'),('board','certifications.grant'),
|
||||
('board','classes.manage'),('board','classes.instruct'),
|
||||
('board','equipment.manage'),
|
||||
('board','customize.manage'),('board','settings.manage'),
|
||||
('treasurer','payments.manage'),('treasurer','ops.kb.read'),('treasurer','ops.process.read'),
|
||||
('member','ops.kb.read'),('member','ops.process.read'),
|
||||
@@ -2220,3 +2221,100 @@ ON CONFLICT (space_id, subject, permission) DO NOTHING;
|
||||
INSERT INTO public.space_role_permissions (space_id, subject, permission)
|
||||
SELECT id, 'board', 'classes.instruct' FROM public.spaces
|
||||
ON CONFLICT (space_id, subject, permission) DO NOTHING;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 20. Equipment registry + reservations
|
||||
-- (equivalent to scripts/033_equipment.sql).
|
||||
--
|
||||
-- equipment (registry; status available/maintenance/retired; optional
|
||||
-- required_certification_id -> certifications; is_active archive),
|
||||
-- equipment_reservations (member time-window reservation; space_id
|
||||
-- denormalized; no DB overlap constraint -- enforced in the validated
|
||||
-- action). Permission equipment.manage (group Equipment). RLS additive/
|
||||
-- default-deny: equipment SELECT = manager (all) or member (is_active);
|
||||
-- equipment_reservations SELECT = manager (all) or member (own), UPDATE =
|
||||
-- equipment.manage, NO INSERT/DELETE policy (reserve/cancel via a
|
||||
-- validated service-client action enforcing status + no-overlap +
|
||||
-- required-cert). No anonymous path. seed_default_role_permissions()
|
||||
-- above already seeds the code; the backfill below covers existing spaces.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.equipment (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
||||
name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 200),
|
||||
description text,
|
||||
location text,
|
||||
status text NOT NULL DEFAULT 'available'
|
||||
CHECK (status IN ('available','maintenance','retired')),
|
||||
required_certification_id uuid REFERENCES public.certifications(id) ON DELETE SET NULL,
|
||||
asset_tag text,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_by uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_equipment_space ON public.equipment (space_id, is_active);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.equipment_reservations (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
equipment_id uuid NOT NULL REFERENCES public.equipment(id) ON DELETE CASCADE,
|
||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
||||
member_id uuid NOT NULL REFERENCES public.space_members(id) ON DELETE CASCADE,
|
||||
starts_at timestamptz NOT NULL,
|
||||
ends_at timestamptz NOT NULL CHECK (ends_at > starts_at),
|
||||
status text NOT NULL DEFAULT 'reserved'
|
||||
CHECK (status IN ('reserved','cancelled','completed')),
|
||||
notes text,
|
||||
created_by uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_equipment_res_space ON public.equipment_reservations (space_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_equipment_res_equipment ON public.equipment_reservations (equipment_id, starts_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_equipment_res_member ON public.equipment_reservations (member_id);
|
||||
|
||||
ALTER TABLE public.equipment ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.equipment_reservations ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS equipment_select ON public.equipment;
|
||||
DROP POLICY IF EXISTS equipment_insert ON public.equipment;
|
||||
DROP POLICY IF EXISTS equipment_update ON public.equipment;
|
||||
DROP POLICY IF EXISTS equipment_delete ON public.equipment;
|
||||
CREATE POLICY equipment_select ON public.equipment FOR SELECT
|
||||
USING (
|
||||
public.user_has_permission(auth.uid(), space_id, 'equipment.manage')
|
||||
OR (
|
||||
space_id IN (SELECT public.get_user_space_ids(auth.uid()))
|
||||
AND is_active
|
||||
)
|
||||
);
|
||||
CREATE POLICY equipment_insert ON public.equipment FOR INSERT
|
||||
WITH CHECK (public.user_has_permission(auth.uid(), space_id, 'equipment.manage'));
|
||||
CREATE POLICY equipment_update ON public.equipment FOR UPDATE
|
||||
USING (public.user_has_permission(auth.uid(), space_id, 'equipment.manage'));
|
||||
CREATE POLICY equipment_delete ON public.equipment FOR DELETE
|
||||
USING (public.user_has_permission(auth.uid(), space_id, 'equipment.manage'));
|
||||
|
||||
DROP POLICY IF EXISTS equipment_reservations_select ON public.equipment_reservations;
|
||||
DROP POLICY IF EXISTS equipment_reservations_update ON public.equipment_reservations;
|
||||
CREATE POLICY equipment_reservations_select ON public.equipment_reservations FOR SELECT
|
||||
USING (
|
||||
public.user_has_permission(auth.uid(), space_id, 'equipment.manage')
|
||||
OR member_id IN (
|
||||
SELECT id FROM public.space_members
|
||||
WHERE user_id = auth.uid() AND space_id = equipment_reservations.space_id
|
||||
)
|
||||
);
|
||||
CREATE POLICY equipment_reservations_update ON public.equipment_reservations FOR UPDATE
|
||||
USING (public.user_has_permission(auth.uid(), space_id, 'equipment.manage'));
|
||||
-- No INSERT/DELETE policy: reserve/cancel via a validated service-client action.
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_equipment_touch ON public.equipment;
|
||||
CREATE TRIGGER trg_equipment_touch
|
||||
BEFORE UPDATE ON public.equipment
|
||||
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
||||
|
||||
INSERT INTO public.space_role_permissions (space_id, subject, permission)
|
||||
SELECT id, 'board', 'equipment.manage' FROM public.spaces
|
||||
ON CONFLICT (space_id, subject, permission) DO NOTHING;
|
||||
|
||||
Reference in New Issue
Block a user