diff --git a/cmd/server/main.go b/cmd/server/main.go index 5519d1e..0fcbb1d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -152,8 +152,10 @@ func main() { eventTypeSvc := services.NewEventTypeService(pool, users) deadlineSvc := services.NewDeadlineService(pool, projectSvc, eventTypeSvc) // t-paliad-225 Slice A — user-authored checklist templates. + // Slice B adds checklist_shares grants + admin promotion. checklistCatalogSvc := services.NewChecklistCatalogService(pool) sysAuditSvc := services.NewSystemAuditLogService(pool) + checklistTemplateSvc := services.NewChecklistTemplateService(pool, checklistCatalogSvc, sysAuditSvc, users) svcBundle = &handlers.Services{ Project: projectSvc, Team: teamSvc, @@ -184,7 +186,9 @@ func main() { Note: services.NewNoteService(pool, projectSvc, appointmentSvc), ChecklistInst: services.NewChecklistInstanceService(pool, projectSvc, checklistCatalogSvc), ChecklistCatalog: checklistCatalogSvc, - ChecklistTemplate: services.NewChecklistTemplateService(pool, checklistCatalogSvc, sysAuditSvc, users), + ChecklistTemplate: checklistTemplateSvc, + ChecklistShare: services.NewChecklistShareService(pool, checklistTemplateSvc, sysAuditSvc, users), + ChecklistPromotion: services.NewChecklistPromotionService(pool, checklistTemplateSvc, sysAuditSvc, users), Mail: mailSvc, Invite: inviteSvc, Agenda: services.NewAgendaService(pool, users, eventTypeSvc), diff --git a/internal/db/migrations/115_checklist_shares.down.sql b/internal/db/migrations/115_checklist_shares.down.sql new file mode 100644 index 0000000..5fdcc86 --- /dev/null +++ b/internal/db/migrations/115_checklist_shares.down.sql @@ -0,0 +1,26 @@ +-- Reverse of mig 115 — t-paliad-225 / m/paliad#61 Slice B. +-- +-- Restore the owner+firm/global-only body of paliad.can_see_checklist +-- (matches the mig 114 definition) so a rollback of Slice B leaves the +-- function pointing at the Slice A behaviour. + +CREATE OR REPLACE FUNCTION paliad.can_see_checklist(_user_id uuid, _checklist_id uuid) +RETURNS boolean +LANGUAGE sql STABLE SECURITY DEFINER +SET search_path TO 'paliad', 'public' +AS $$ + SELECT EXISTS ( + SELECT 1 FROM paliad.checklists c + WHERE c.id = _checklist_id AND c.owner_id = _user_id + ) + OR EXISTS ( + SELECT 1 FROM paliad.checklists c + WHERE c.id = _checklist_id AND c.visibility IN ('firm', 'global') + ); +$$; + +DROP POLICY IF EXISTS checklist_shares_delete ON paliad.checklist_shares; +DROP POLICY IF EXISTS checklist_shares_insert ON paliad.checklist_shares; +DROP POLICY IF EXISTS checklist_shares_select ON paliad.checklist_shares; + +DROP TABLE IF EXISTS paliad.checklist_shares; diff --git a/internal/db/migrations/115_checklist_shares.up.sql b/internal/db/migrations/115_checklist_shares.up.sql new file mode 100644 index 0000000..fc158e0 --- /dev/null +++ b/internal/db/migrations/115_checklist_shares.up.sql @@ -0,0 +1,211 @@ +-- mig 115 — t-paliad-225 / m/paliad#61 Slice B — explicit sharing + +-- admin-promotion plumbing for user-authored checklists. +-- +-- Design: docs/design-user-checklists-2026-05-20.md §3.2 / §4.2 / §4.3 +-- / §4.5. +-- +-- Introduces paliad.checklist_shares with the polymorphic recipient +-- pattern (xor-check enforces exactly one recipient_* column populated +-- per recipient_kind). Extends paliad.can_see_checklist with the +-- explicit-share branches so the 'shared' visibility level actually +-- gates anything. +-- +-- Sections: +-- 1. CREATE TABLE paliad.checklist_shares (+ indexes + RLS). +-- 2. CREATE OR REPLACE paliad.can_see_checklist — adds 4 share +-- branches (user / office / partner_unit / project). +-- +-- Idempotent throughout. + +-- ============================================================================ +-- 1. paliad.checklist_shares — explicit grants for a single checklist. +-- +-- recipient_kind disambiguates which recipient_* column is populated. +-- The XOR check makes the constraint structurally enforce "exactly one +-- recipient_ non-null per row". Per-kind UNIQUE partial indexes +-- prevent duplicate grants per (checklist, recipient). +-- +-- Slice A's checklists.visibility CHECK already includes 'shared' so no +-- ALTER is needed here. +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS paliad.checklist_shares ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + checklist_id uuid NOT NULL REFERENCES paliad.checklists(id) ON DELETE CASCADE, + recipient_kind text NOT NULL + CHECK (recipient_kind IN ('user', 'office', 'partner_unit', 'project')), + recipient_user_id uuid REFERENCES paliad.users(id) ON DELETE CASCADE, + recipient_office text, + recipient_partner_unit_id uuid REFERENCES paliad.partner_units(id) ON DELETE CASCADE, + recipient_project_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE, + granted_by uuid NOT NULL REFERENCES paliad.users(id) ON DELETE SET NULL, + granted_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT checklist_shares_recipient_xor CHECK ( + (recipient_kind = 'user' + AND recipient_user_id IS NOT NULL + AND recipient_office IS NULL + AND recipient_partner_unit_id IS NULL + AND recipient_project_id IS NULL) + OR (recipient_kind = 'office' + AND recipient_office IS NOT NULL + AND recipient_user_id IS NULL + AND recipient_partner_unit_id IS NULL + AND recipient_project_id IS NULL) + OR (recipient_kind = 'partner_unit' + AND recipient_partner_unit_id IS NOT NULL + AND recipient_user_id IS NULL + AND recipient_office IS NULL + AND recipient_project_id IS NULL) + OR (recipient_kind = 'project' + AND recipient_project_id IS NOT NULL + AND recipient_user_id IS NULL + AND recipient_office IS NULL + AND recipient_partner_unit_id IS NULL) + ) +); + +-- Hot-path lookup for the visibility predicate. +CREATE INDEX IF NOT EXISTS checklist_shares_lookup_idx + ON paliad.checklist_shares (checklist_id); + +-- Uniqueness per recipient kind. Partial indexes so a NULL recipient_ +-- doesn't collide with another row's NULL recipient_. +CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_user_uniq + ON paliad.checklist_shares (checklist_id, recipient_user_id) + WHERE recipient_kind = 'user'; +CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_office_uniq + ON paliad.checklist_shares (checklist_id, recipient_office) + WHERE recipient_kind = 'office'; +CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_partner_unit_uniq + ON paliad.checklist_shares (checklist_id, recipient_partner_unit_id) + WHERE recipient_kind = 'partner_unit'; +CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_project_uniq + ON paliad.checklist_shares (checklist_id, recipient_project_id) + WHERE recipient_kind = 'project'; + +COMMENT ON TABLE paliad.checklist_shares IS + 'Explicit grants for paliad.checklists. Polymorphic recipient ' + '(user/office/partner_unit/project) enforced by recipient_xor CHECK. ' + 'Owner of the checklist grants and revokes; global_admin can revoke ' + 'as well. Slice B (t-paliad-225) — see can_see_checklist body for ' + 'the visibility branches that consume these rows.'; + +ALTER TABLE paliad.checklist_shares ENABLE ROW LEVEL SECURITY; + +-- SELECT: caller can see the row if they own the parent checklist OR +-- they are the recipient (for user-kind grants — recipients shouldn't +-- be surprised by who else can also see the checklist) OR global_admin. +DROP POLICY IF EXISTS checklist_shares_select ON paliad.checklist_shares; +CREATE POLICY checklist_shares_select + ON paliad.checklist_shares FOR SELECT TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM paliad.checklists c + WHERE c.id = checklist_id AND c.owner_id = auth.uid() + ) + OR (recipient_kind = 'user' AND recipient_user_id = auth.uid()) + OR EXISTS ( + SELECT 1 FROM paliad.users u + WHERE u.id = auth.uid() AND u.global_role = 'global_admin' + ) + ); + +-- INSERT: only the checklist owner can grant; granted_by must be self. +DROP POLICY IF EXISTS checklist_shares_insert ON paliad.checklist_shares; +CREATE POLICY checklist_shares_insert + ON paliad.checklist_shares FOR INSERT TO authenticated + WITH CHECK ( + EXISTS ( + SELECT 1 FROM paliad.checklists c + WHERE c.id = checklist_id AND c.owner_id = auth.uid() + ) + AND granted_by = auth.uid() + ); + +-- DELETE: owner OR global_admin. No UPDATE policy — grants are +-- immutable, revoke = DELETE + re-insert with the corrected recipient. +DROP POLICY IF EXISTS checklist_shares_delete ON paliad.checklist_shares; +CREATE POLICY checklist_shares_delete + ON paliad.checklist_shares FOR DELETE TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM paliad.checklists c + WHERE c.id = checklist_id AND c.owner_id = auth.uid() + ) + OR EXISTS ( + SELECT 1 FROM paliad.users u + WHERE u.id = auth.uid() AND u.global_role = 'global_admin' + ) + ); + +-- ============================================================================ +-- 2. paliad.can_see_checklist — extend with the 4 share branches. +-- +-- Owner + firm/global branches stay as in mig 114. Share branches: +-- - user — the row's recipient_user_id matches the caller +-- - office — recipient_office matches caller's office OR is in +-- their additional_offices array +-- - partner_unit — caller is a member of the recipient partner_unit +-- - project — caller can see the recipient project (reuses +-- paliad.can_see_project, ltree-walked) +-- +-- can_see_project reads auth.uid() through SECURITY DEFINER inheritance +-- (same pattern effective_project_admin uses in mig 111). +-- ============================================================================ + +CREATE OR REPLACE FUNCTION paliad.can_see_checklist(_user_id uuid, _checklist_id uuid) +RETURNS boolean +LANGUAGE sql STABLE SECURITY DEFINER +SET search_path TO 'paliad', 'public' +AS $$ + -- Owner + SELECT EXISTS ( + SELECT 1 FROM paliad.checklists c + WHERE c.id = _checklist_id AND c.owner_id = _user_id + ) + -- firm / global + OR EXISTS ( + SELECT 1 FROM paliad.checklists c + WHERE c.id = _checklist_id AND c.visibility IN ('firm', 'global') + ) + -- Explicit share: user + OR EXISTS ( + SELECT 1 FROM paliad.checklist_shares s + WHERE s.checklist_id = _checklist_id + AND s.recipient_kind = 'user' + AND s.recipient_user_id = _user_id + ) + -- Explicit share: office (caller's primary OR additional offices) + OR EXISTS ( + SELECT 1 + FROM paliad.checklist_shares s + JOIN paliad.users u ON u.id = _user_id + WHERE s.checklist_id = _checklist_id + AND s.recipient_kind = 'office' + AND (s.recipient_office = u.office + OR s.recipient_office = ANY(u.additional_offices)) + ) + -- Explicit share: partner_unit (caller is a member) + OR EXISTS ( + SELECT 1 + FROM paliad.checklist_shares s + JOIN paliad.partner_unit_members pum + ON pum.partner_unit_id = s.recipient_partner_unit_id + AND pum.user_id = _user_id + WHERE s.checklist_id = _checklist_id + AND s.recipient_kind = 'partner_unit' + ) + -- Explicit share: project (caller can see the project via existing predicate) + OR EXISTS ( + SELECT 1 FROM paliad.checklist_shares s + WHERE s.checklist_id = _checklist_id + AND s.recipient_kind = 'project' + AND paliad.can_see_project(s.recipient_project_id) + ); +$$; + +COMMENT ON FUNCTION paliad.can_see_checklist(uuid, uuid) IS + 'True iff the user owns the checklist OR firm/global visibility OR ' + 'an explicit share row matches the caller (by user / office / ' + 'partner_unit / project ancestry).'; diff --git a/internal/handlers/checklist_shares.go b/internal/handlers/checklist_shares.go new file mode 100644 index 0000000..60fea7e --- /dev/null +++ b/internal/handlers/checklist_shares.go @@ -0,0 +1,131 @@ +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/google/uuid" + + "mgit.msbls.de/m/paliad/internal/services" +) + +// GET /api/checklists/templates/{slug}/shares — list grants (owner/admin). +func handleListChecklistShares(w http.ResponseWriter, r *http.Request) { + if !requireDB(w) { + return + } + uid, ok := requireUser(w, r) + if !ok { + return + } + slug := r.PathValue("slug") + rows, err := dbSvc.checklistShare.ListGrants(r.Context(), uid, slug) + if err != nil { + writeChecklistShareError(w, err) + return + } + writeJSON(w, http.StatusOK, rows) +} + +// POST /api/checklists/templates/{slug}/shares — grant a share. +func handleGrantChecklistShare(w http.ResponseWriter, r *http.Request) { + if !requireDB(w) { + return + } + uid, ok := requireUser(w, r) + if !ok { + return + } + slug := r.PathValue("slug") + var input services.ShareGrantInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + share, err := dbSvc.checklistShare.Grant(r.Context(), uid, slug, input) + if err != nil { + writeChecklistShareError(w, err) + return + } + writeJSON(w, http.StatusCreated, share) +} + +// DELETE /api/checklists/shares/{id} — revoke a share by id. +func handleRevokeChecklistShare(w http.ResponseWriter, r *http.Request) { + if !requireDB(w) { + return + } + uid, ok := requireUser(w, r) + if !ok { + return + } + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) + return + } + if err := dbSvc.checklistShare.Revoke(r.Context(), uid, id); err != nil { + writeChecklistShareError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// POST /api/admin/checklists/{slug}/promote — global_admin only. +func handlePromoteChecklist(w http.ResponseWriter, r *http.Request) { + if !requireDB(w) { + return + } + uid, ok := requireUser(w, r) + if !ok { + return + } + slug := r.PathValue("slug") + if err := dbSvc.checklistPromotion.Promote(r.Context(), uid, slug); err != nil { + writeChecklistShareError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// POST /api/admin/checklists/{slug}/demote — global_admin only. +func handleDemoteChecklist(w http.ResponseWriter, r *http.Request) { + if !requireDB(w) { + return + } + uid, ok := requireUser(w, r) + if !ok { + return + } + slug := r.PathValue("slug") + var body struct { + Target string `json:"target"` + } + // Body is optional — Demote defaults to 'firm' when empty. + _ = json.NewDecoder(r.Body).Decode(&body) + if err := dbSvc.checklistPromotion.Demote(r.Context(), uid, slug, body.Target); err != nil { + writeChecklistShareError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// writeChecklistShareError maps the share/promotion service errors. +// Same as the templates handler: ErrInvalidInput → 400, ErrForbidden → +// 403, ErrNotVisible → 404, fall through to writeServiceError. +func writeChecklistShareError(w http.ResponseWriter, err error) { + if errors.Is(err, services.ErrInvalidInput) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if errors.Is(err, services.ErrForbidden) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()}) + return + } + if errors.Is(err, services.ErrNotVisible) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "checklist not found"}) + return + } + writeServiceError(w, err) +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 4446c3e..660e1c2 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -70,9 +70,11 @@ type Services struct { EventType *services.EventTypeService Dashboard *services.DashboardService Note *services.NoteService - ChecklistInst *services.ChecklistInstanceService - ChecklistCatalog *services.ChecklistCatalogService - ChecklistTemplate *services.ChecklistTemplateService + ChecklistInst *services.ChecklistInstanceService + ChecklistCatalog *services.ChecklistCatalogService + ChecklistTemplate *services.ChecklistTemplateService + ChecklistShare *services.ChecklistShareService + ChecklistPromotion *services.ChecklistPromotionService Mail *services.MailService Invite *services.InviteService Agenda *services.AgendaService @@ -146,9 +148,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc eventType: svc.EventType, dashboard: svc.Dashboard, note: svc.Note, - checklistInst: svc.ChecklistInst, - checklistCatalog: svc.ChecklistCatalog, - checklistTemplate: svc.ChecklistTemplate, + checklistInst: svc.ChecklistInst, + checklistCatalog: svc.ChecklistCatalog, + checklistTemplate: svc.ChecklistTemplate, + checklistShare: svc.ChecklistShare, + checklistPromotion: svc.ChecklistPromotion, mail: svc.Mail, invite: svc.Invite, agenda: svc.Agenda, @@ -265,6 +269,12 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc protected.HandleFunc("PATCH /api/checklists/templates/{slug}", handleUpdateChecklistTemplate) protected.HandleFunc("PATCH /api/checklists/templates/{slug}/visibility", handleSetChecklistTemplateVisibility) protected.HandleFunc("DELETE /api/checklists/templates/{slug}", handleDeleteChecklistTemplate) + // t-paliad-225 Slice B — explicit sharing + admin promotion. + protected.HandleFunc("GET /api/checklists/templates/{slug}/shares", handleListChecklistShares) + protected.HandleFunc("POST /api/checklists/templates/{slug}/shares", handleGrantChecklistShare) + protected.HandleFunc("DELETE /api/checklists/shares/{id}", handleRevokeChecklistShare) + protected.HandleFunc("POST /api/admin/checklists/{slug}/promote", handlePromoteChecklist) + protected.HandleFunc("POST /api/admin/checklists/{slug}/demote", handleDemoteChecklist) protected.HandleFunc("GET /api/checklists/{slug}/instances", handleListChecklistInstancesForTemplate) protected.HandleFunc("POST /api/checklists/{slug}/instances", handleCreateChecklistInstance) protected.HandleFunc("GET /api/checklist-instances", handleListAllChecklistInstances) diff --git a/internal/handlers/projects.go b/internal/handlers/projects.go index de54a4e..96658b1 100644 --- a/internal/handlers/projects.go +++ b/internal/handlers/projects.go @@ -38,9 +38,11 @@ type dbServices struct { eventType *services.EventTypeService dashboard *services.DashboardService note *services.NoteService - checklistInst *services.ChecklistInstanceService - checklistCatalog *services.ChecklistCatalogService - checklistTemplate *services.ChecklistTemplateService + checklistInst *services.ChecklistInstanceService + checklistCatalog *services.ChecklistCatalogService + checklistTemplate *services.ChecklistTemplateService + checklistShare *services.ChecklistShareService + checklistPromotion *services.ChecklistPromotionService mail *services.MailService invite *services.InviteService agenda *services.AgendaService diff --git a/internal/services/checklist_catalog_service.go b/internal/services/checklist_catalog_service.go index 0b72453..b8fd075 100644 --- a/internal/services/checklist_catalog_service.go +++ b/internal/services/checklist_catalog_service.go @@ -179,19 +179,68 @@ const authoredWithOwnerSelect = `SELECT c.id, c.slug, c.owner_id, c.title, c.des JOIN paliad.users u ON u.id = c.owner_id` // checklistVisibilityPredicate mirrors paliad.can_see_checklist for the -// service-role connection (which bypasses RLS). Slice A covers owner + -// firm/global; Slice B will extend with the explicit-share path. +// service-role connection (which bypasses RLS). Covers all 6 branches +// from mig 115: owner + firm/global + global_admin + 4 share-recipient +// kinds (user / office / partner_unit / project). // -// Two positional args expected: ($userArg) the caller UUID. Reused -// twice (owner-check + global_admin shortcut). Slice B will add a third -// branch over paliad.checklist_shares. +// One positional arg ($userArg) for the caller UUID. Reused several +// times across the branches; that's fine — Postgres positional +// placeholders evaluate the arg once per reference, no extra param +// binding overhead. func checklistVisibilityPredicate(alias string, userArg int) string { return fmt.Sprintf(`(%s.owner_id = $%d OR %s.visibility IN ('firm', 'global') OR EXISTS ( SELECT 1 FROM paliad.users u WHERE u.id = $%d AND u.global_role = 'global_admin' - ))`, alias, userArg, alias, userArg) + ) + OR EXISTS ( + SELECT 1 FROM paliad.checklist_shares s + WHERE s.checklist_id = %s.id + AND s.recipient_kind = 'user' + AND s.recipient_user_id = $%d + ) + OR EXISTS ( + SELECT 1 + FROM paliad.checklist_shares s + JOIN paliad.users u ON u.id = $%d + WHERE s.checklist_id = %s.id + AND s.recipient_kind = 'office' + AND (s.recipient_office = u.office + OR s.recipient_office = ANY(u.additional_offices)) + ) + OR EXISTS ( + SELECT 1 + FROM paliad.checklist_shares s + JOIN paliad.partner_unit_members pum + ON pum.partner_unit_id = s.recipient_partner_unit_id + AND pum.user_id = $%d + WHERE s.checklist_id = %s.id + AND s.recipient_kind = 'partner_unit' + ) + OR EXISTS ( + -- Share-to-project resolution: inline ltree walk over + -- paliad.projects.path because paliad.can_see_project + -- reads auth.uid() which is NULL on the service-role + -- connection (same pattern as visibility.go). + SELECT 1 + FROM paliad.checklist_shares s + JOIN paliad.projects p + ON p.id = s.recipient_project_id + JOIN paliad.project_teams pt + ON pt.user_id = $%d + AND pt.project_id = ANY(CAST(string_to_array(p.path, '.') AS uuid[])) + WHERE s.checklist_id = %s.id + AND s.recipient_kind = 'project' + ))`, + alias, userArg, // owner + alias, // firm/global visibility col + userArg, // global_admin + alias, userArg, // share: user + userArg, alias, // share: office + userArg, alias, // share: partner_unit + userArg, alias, // share: project (ltree walk) + ) } func (s *ChecklistCatalogService) fetchVisibleAuthored(ctx context.Context, userID uuid.UUID) ([]models.ChecklistWithOwner, error) { diff --git a/internal/services/checklist_promotion_service.go b/internal/services/checklist_promotion_service.go new file mode 100644 index 0000000..64525f9 --- /dev/null +++ b/internal/services/checklist_promotion_service.go @@ -0,0 +1,153 @@ +package services + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +// ChecklistPromotionService implements the global_admin-only promote / +// demote flow for paliad.checklists. Promote flips visibility to +// 'global' and stamps promoted_at / promoted_by; demote flips it back +// to a caller-chosen target ('firm' default — preserves visibility for +// already-instantiated users). +type ChecklistPromotionService struct { + db *sqlx.DB + templates *ChecklistTemplateService + audit *SystemAuditLogService + users *UserService +} + +func NewChecklistPromotionService(db *sqlx.DB, templates *ChecklistTemplateService, audit *SystemAuditLogService, users *UserService) *ChecklistPromotionService { + return &ChecklistPromotionService{db: db, templates: templates, audit: audit, users: users} +} + +// validDemoteTargets — narrowing the visibility back from 'global' is +// only allowed to a state where the row is still meaningful. 'global' +// would be a no-op; 'shared' would orphan existing instance owners who +// already see it without a grant. Default is 'firm'. +var validDemoteTargets = map[string]bool{"firm": true, "private": true} + +// Promote flips an authored template to visibility='global'. Caller +// must be global_admin. Emits 'checklist.promoted_global' audit event +// with the prior visibility captured for the demote-undo path. +func (s *ChecklistPromotionService) Promote(ctx context.Context, callerID uuid.UUID, slug string) error { + if err := s.requireGlobalAdmin(ctx, callerID); err != nil { + return err + } + row, err := s.templates.GetBySlug(ctx, callerID, slug) + if err != nil { + return err + } + if row.Visibility == "global" { + return nil + } + + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("begin promote tx: %w", err) + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, + `UPDATE paliad.checklists + SET visibility = 'global', + promoted_at = $2, + promoted_by = $3, + updated_at = $2 + WHERE id = $1`, row.ID, time.Now().UTC(), callerID); err != nil { + return fmt.Errorf("promote checklist: %w", err) + } + + actorEmail, _ := s.actorEmail(ctx, callerID) + if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{ + EventType: "checklist.promoted_global", + ActorID: callerID, + ActorEmail: actorEmail, + Metadata: map[string]any{ + "checklist_id": row.ID, + "slug": slug, + "owner_id": row.OwnerID, + "prior_visibility": row.Visibility, + }, + }); err != nil { + return err + } + return tx.Commit() +} + +// Demote narrows visibility from 'global' to target. target defaults to +// 'firm' when empty. promoted_at / promoted_by are cleared. +func (s *ChecklistPromotionService) Demote(ctx context.Context, callerID uuid.UUID, slug, target string) error { + if err := s.requireGlobalAdmin(ctx, callerID); err != nil { + return err + } + row, err := s.templates.GetBySlug(ctx, callerID, slug) + if err != nil { + return err + } + t := strings.ToLower(strings.TrimSpace(target)) + if t == "" { + t = "firm" + } + if !validDemoteTargets[t] { + return fmt.Errorf("%w: demote target must be firm | private, got %q", ErrInvalidInput, target) + } + if row.Visibility != "global" { + return fmt.Errorf("%w: checklist is not currently promoted (visibility=%s)", ErrInvalidInput, row.Visibility) + } + + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("begin demote tx: %w", err) + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, + `UPDATE paliad.checklists + SET visibility = $2, + promoted_at = NULL, + promoted_by = NULL, + updated_at = now() + WHERE id = $1`, row.ID, t); err != nil { + return fmt.Errorf("demote checklist: %w", err) + } + + actorEmail, _ := s.actorEmail(ctx, callerID) + if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{ + EventType: "checklist.demoted", + ActorID: callerID, + ActorEmail: actorEmail, + Metadata: map[string]any{ + "checklist_id": row.ID, + "slug": slug, + "target_visibility": t, + }, + }); err != nil { + return err + } + return tx.Commit() +} + +func (s *ChecklistPromotionService) requireGlobalAdmin(ctx context.Context, callerID uuid.UUID) error { + user, err := s.users.GetByID(ctx, callerID) + if err != nil { + return err + } + if user == nil || user.GlobalRole != "global_admin" { + return fmt.Errorf("%w: only global_admin can promote / demote checklists", ErrForbidden) + } + return nil +} + +func (s *ChecklistPromotionService) actorEmail(ctx context.Context, userID uuid.UUID) (string, error) { + u, err := s.users.GetByID(ctx, userID) + if err != nil || u == nil { + return "", err + } + return u.Email, nil +} diff --git a/internal/services/checklist_share_service.go b/internal/services/checklist_share_service.go new file mode 100644 index 0000000..00d0c47 --- /dev/null +++ b/internal/services/checklist_share_service.go @@ -0,0 +1,331 @@ +package services + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "mgit.msbls.de/m/paliad/internal/offices" +) + +// ChecklistShareService is the write surface for paliad.checklist_shares +// (mig 115). Owners grant; owner-or-global_admin revokes. ListGrants is +// owner-only (returning all 4 recipient kinds) — recipients see "this +// is shared with me" only implicitly via the visibility predicate. +type ChecklistShareService struct { + db *sqlx.DB + templates *ChecklistTemplateService + audit *SystemAuditLogService + users *UserService +} + +func NewChecklistShareService(db *sqlx.DB, templates *ChecklistTemplateService, audit *SystemAuditLogService, users *UserService) *ChecklistShareService { + return &ChecklistShareService{db: db, templates: templates, audit: audit, users: users} +} + +// ShareGrantInput is the POST body for granting a share. Exactly one +// of the recipient_* fields must be set, matching recipient_kind. +type ShareGrantInput struct { + RecipientKind string `json:"recipient_kind"` + UserID *uuid.UUID `json:"recipient_user_id,omitempty"` + Office string `json:"recipient_office,omitempty"` + PartnerUnitID *uuid.UUID `json:"recipient_partner_unit_id,omitempty"` + ProjectID *uuid.UUID `json:"recipient_project_id,omitempty"` +} + +// Share is the row shape returned from list / grant calls. +type Share struct { + ID uuid.UUID `db:"id" json:"id"` + ChecklistID uuid.UUID `db:"checklist_id" json:"checklist_id"` + RecipientKind string `db:"recipient_kind" json:"recipient_kind"` + RecipientUserID *uuid.UUID `db:"recipient_user_id" json:"recipient_user_id,omitempty"` + RecipientOffice *string `db:"recipient_office" json:"recipient_office,omitempty"` + RecipientPartnerUnitID *uuid.UUID `db:"recipient_partner_unit_id" json:"recipient_partner_unit_id,omitempty"` + RecipientProjectID *uuid.UUID `db:"recipient_project_id" json:"recipient_project_id,omitempty"` + GrantedBy uuid.UUID `db:"granted_by" json:"granted_by"` + GrantedAt time.Time `db:"granted_at" json:"granted_at"` + // Display-name enrichment for the recipient — owners want to see + // "Sarah Schmidt" not just a UUID on the grants list. + RecipientLabel string `db:"recipient_label" json:"recipient_label"` +} + +// Grant creates a new share row. Caller must own the parent checklist +// (or be global_admin). Recipient validity (FK targets exist + kind +// matches the populated recipient_* column) enforced before INSERT. +func (s *ChecklistShareService) Grant(ctx context.Context, callerID uuid.UUID, slug string, input ShareGrantInput) (*Share, error) { + row, err := s.templates.GetBySlug(ctx, callerID, slug) + if err != nil { + return nil, err + } + // Ownership check — Grant is owner-only (global_admin can demote + // global templates but doesn't author shares). + if row.OwnerID != callerID { + return nil, fmt.Errorf("%w: only the owner can grant shares", ErrForbidden) + } + + kind := strings.ToLower(strings.TrimSpace(input.RecipientKind)) + if err := validateShareInput(kind, input); err != nil { + return nil, err + } + + id := uuid.New() + now := time.Now().UTC() + + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin grant tx: %w", err) + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, + `INSERT INTO paliad.checklist_shares + (id, checklist_id, recipient_kind, recipient_user_id, recipient_office, + recipient_partner_unit_id, recipient_project_id, granted_by, granted_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + id, row.ID, kind, + input.UserID, nullableString(input.Office), input.PartnerUnitID, input.ProjectID, + callerID, now, + ); err != nil { + // Map the partial-unique-index conflict into a friendly 409. + if pqUniqueViolation(err) { + return nil, fmt.Errorf("%w: this recipient already has a grant on this checklist", ErrInvalidInput) + } + return nil, fmt.Errorf("insert checklist_share: %w", err) + } + + actorEmail, _ := s.actorEmail(ctx, callerID) + meta := map[string]any{ + "checklist_id": row.ID, + "slug": slug, + "share_id": id, + "recipient_kind": kind, + } + switch kind { + case "user": + meta["recipient_user_id"] = input.UserID + case "office": + meta["recipient_office"] = input.Office + case "partner_unit": + meta["recipient_partner_unit_id"] = input.PartnerUnitID + case "project": + meta["recipient_project_id"] = input.ProjectID + } + if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{ + EventType: "checklist.shared", + ActorID: callerID, + ActorEmail: actorEmail, + Metadata: meta, + }); err != nil { + return nil, err + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit grant: %w", err) + } + return s.getShareByID(ctx, callerID, id) +} + +// Revoke deletes a share row. Owner of the parent checklist OR +// global_admin. Audited as 'checklist.unshared' with the recipient meta +// captured pre-delete. +func (s *ChecklistShareService) Revoke(ctx context.Context, callerID uuid.UUID, shareID uuid.UUID) error { + share, err := s.getShareByID(ctx, callerID, shareID) + if err != nil { + return err + } + // Resolve owner of the parent checklist for the authorization gate. + // templates.GetBySlug needs a slug we don't have; inline a minimal + // owner lookup keyed on the share's checklist_id. + var ownerID uuid.UUID + if err := s.db.GetContext(ctx, &ownerID, + `SELECT owner_id FROM paliad.checklists WHERE id = $1`, share.ChecklistID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrNotVisible + } + return fmt.Errorf("fetch checklist owner: %w", err) + } + if ownerID != callerID { + user, err := s.users.GetByID(ctx, callerID) + if err != nil { + return err + } + if user == nil || user.GlobalRole != "global_admin" { + return fmt.Errorf("%w: only the owner or a global_admin can revoke a share", ErrForbidden) + } + } + + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("begin revoke tx: %w", err) + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, + `DELETE FROM paliad.checklist_shares WHERE id = $1`, shareID); err != nil { + return fmt.Errorf("delete checklist_share: %w", err) + } + + actorEmail, _ := s.actorEmail(ctx, callerID) + meta := map[string]any{ + "checklist_id": share.ChecklistID, + "share_id": share.ID, + "recipient_kind": share.RecipientKind, + } + switch share.RecipientKind { + case "user": + meta["recipient_user_id"] = share.RecipientUserID + case "office": + meta["recipient_office"] = share.RecipientOffice + case "partner_unit": + meta["recipient_partner_unit_id"] = share.RecipientPartnerUnitID + case "project": + meta["recipient_project_id"] = share.RecipientProjectID + } + if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{ + EventType: "checklist.unshared", + ActorID: callerID, + ActorEmail: actorEmail, + Metadata: meta, + }); err != nil { + return err + } + return tx.Commit() +} + +// ListGrants returns every share row for the checklist. Owner-only — +// recipients only learn about shares affecting them implicitly via the +// visibility predicate. +func (s *ChecklistShareService) ListGrants(ctx context.Context, callerID uuid.UUID, slug string) ([]Share, error) { + row, err := s.templates.GetBySlug(ctx, callerID, slug) + if err != nil { + return nil, err + } + if row.OwnerID != callerID { + user, err := s.users.GetByID(ctx, callerID) + if err != nil { + return nil, err + } + if user == nil || user.GlobalRole != "global_admin" { + return nil, fmt.Errorf("%w: only the owner or a global_admin can list shares", ErrForbidden) + } + } + + rows := []Share{} + q := `SELECT s.id, s.checklist_id, s.recipient_kind, s.recipient_user_id, + s.recipient_office, s.recipient_partner_unit_id, s.recipient_project_id, + s.granted_by, s.granted_at, + COALESCE( + CASE s.recipient_kind + WHEN 'user' THEN ru.display_name + WHEN 'office' THEN s.recipient_office + WHEN 'partner_unit' THEN pu.name + WHEN 'project' THEN COALESCE(pr.reference, pr.title) + END, + '' + ) AS recipient_label + FROM paliad.checklist_shares s + LEFT JOIN paliad.users ru ON ru.id = s.recipient_user_id + LEFT JOIN paliad.partner_units pu ON pu.id = s.recipient_partner_unit_id + LEFT JOIN paliad.projects pr ON pr.id = s.recipient_project_id + WHERE s.checklist_id = $1 + ORDER BY s.granted_at DESC` + if err := s.db.SelectContext(ctx, &rows, q, row.ID); err != nil { + return nil, fmt.Errorf("list checklist_shares: %w", err) + } + return rows, nil +} + +// --- internals ------------------------------------------------------------ + +func (s *ChecklistShareService) getShareByID(ctx context.Context, callerID uuid.UUID, shareID uuid.UUID) (*Share, error) { + var row Share + q := `SELECT s.id, s.checklist_id, s.recipient_kind, s.recipient_user_id, + s.recipient_office, s.recipient_partner_unit_id, s.recipient_project_id, + s.granted_by, s.granted_at, + COALESCE( + CASE s.recipient_kind + WHEN 'user' THEN ru.display_name + WHEN 'office' THEN s.recipient_office + WHEN 'partner_unit' THEN pu.name + WHEN 'project' THEN COALESCE(pr.reference, pr.title) + END, + '' + ) AS recipient_label + FROM paliad.checklist_shares s + LEFT JOIN paliad.users ru ON ru.id = s.recipient_user_id + LEFT JOIN paliad.partner_units pu ON pu.id = s.recipient_partner_unit_id + LEFT JOIN paliad.projects pr ON pr.id = s.recipient_project_id + WHERE s.id = $1` + err := s.db.GetContext(ctx, &row, q, shareID) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotVisible + } + if err != nil { + return nil, fmt.Errorf("fetch checklist_share: %w", err) + } + return &row, nil +} + +func (s *ChecklistShareService) actorEmail(ctx context.Context, userID uuid.UUID) (string, error) { + u, err := s.users.GetByID(ctx, userID) + if err != nil || u == nil { + return "", err + } + return u.Email, nil +} + +// --- pure helpers --------------------------------------------------------- + +func validateShareInput(kind string, input ShareGrantInput) error { + switch kind { + case "user": + if input.UserID == nil { + return fmt.Errorf("%w: recipient_user_id required when recipient_kind=user", ErrInvalidInput) + } + case "office": + off := strings.TrimSpace(input.Office) + if off == "" { + return fmt.Errorf("%w: recipient_office required when recipient_kind=office", ErrInvalidInput) + } + if !offices.IsValid(off) { + return fmt.Errorf("%w: unknown office %q", ErrInvalidInput, off) + } + case "partner_unit": + if input.PartnerUnitID == nil { + return fmt.Errorf("%w: recipient_partner_unit_id required when recipient_kind=partner_unit", ErrInvalidInput) + } + case "project": + if input.ProjectID == nil { + return fmt.Errorf("%w: recipient_project_id required when recipient_kind=project", ErrInvalidInput) + } + default: + return fmt.Errorf("%w: recipient_kind must be user|office|partner_unit|project, got %q", ErrInvalidInput, kind) + } + return nil +} + +func nullableString(s string) any { + t := strings.TrimSpace(s) + if t == "" { + return nil + } + return t +} + +// pqUniqueViolation reports whether the error is a Postgres +// unique_violation (SQLSTATE 23505). lib/pq exposes it via the .Code() +// method; sqlx surfaces it untouched. We sniff via the error string to +// avoid pulling in lib/pq's Error type here. +func pqUniqueViolation(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "23505") || strings.Contains(msg, "duplicate key") +} diff --git a/internal/services/checklist_share_service_test.go b/internal/services/checklist_share_service_test.go new file mode 100644 index 0000000..030877e --- /dev/null +++ b/internal/services/checklist_share_service_test.go @@ -0,0 +1,107 @@ +package services + +import ( + "errors" + "strings" + "testing" + + "github.com/google/uuid" +) + +func TestValidateShareInput(t *testing.T) { + uid := uuid.New() + puID := uuid.New() + prID := uuid.New() + + cases := []struct { + name string + kind string + input ShareGrantInput + wantErr bool + }{ + {"user happy", "user", ShareGrantInput{RecipientKind: "user", UserID: &uid}, false}, + {"user missing id", "user", ShareGrantInput{RecipientKind: "user"}, true}, + {"office happy", "office", ShareGrantInput{RecipientKind: "office", Office: "munich"}, false}, + {"office unknown key", "office", ShareGrantInput{RecipientKind: "office", Office: "atlantis"}, true}, + {"office empty", "office", ShareGrantInput{RecipientKind: "office"}, true}, + {"partner_unit happy", "partner_unit", ShareGrantInput{RecipientKind: "partner_unit", PartnerUnitID: &puID}, false}, + {"partner_unit missing id", "partner_unit", ShareGrantInput{RecipientKind: "partner_unit"}, true}, + {"project happy", "project", ShareGrantInput{RecipientKind: "project", ProjectID: &prID}, false}, + {"project missing id", "project", ShareGrantInput{RecipientKind: "project"}, true}, + {"unknown kind", "bogus", ShareGrantInput{RecipientKind: "bogus"}, true}, + } + for _, c := range cases { + err := validateShareInput(c.kind, c.input) + if c.wantErr && !errors.Is(err, ErrInvalidInput) { + t.Errorf("%s: expected ErrInvalidInput, got %v", c.name, err) + } + if !c.wantErr && err != nil { + t.Errorf("%s: unexpected error %v", c.name, err) + } + } +} + +func TestPredicateIncludesAllShareBranches(t *testing.T) { + pred := checklistVisibilityPredicate("c", 1) + wants := []string{ + "c.owner_id = $1", + "c.visibility IN ('firm', 'global')", + "u.global_role = 'global_admin'", + "s.recipient_kind = 'user'", + "s.recipient_kind = 'office'", + "s.recipient_kind = 'partner_unit'", + "s.recipient_kind = 'project'", + "paliad.checklist_shares", + "paliad.partner_unit_members", + "paliad.projects", + "paliad.project_teams", + } + for _, w := range wants { + if !strings.Contains(pred, w) { + t.Errorf("predicate missing %q in:\n%s", w, pred) + } + } +} + +func TestPqUniqueViolationDetection(t *testing.T) { + cases := []struct { + err string + want bool + }{ + {"pq: duplicate key value violates unique constraint \"checklist_shares_user_uniq\"", true}, + {"pq: 23505 something", true}, + {"some other error", false}, + } + for _, c := range cases { + got := pqUniqueViolation(errors.New(c.err)) + if got != c.want { + t.Errorf("pqUniqueViolation(%q) = %v; want %v", c.err, got, c.want) + } + } + if pqUniqueViolation(nil) { + t.Error("nil err should not be a unique violation") + } +} + +func TestNullableString(t *testing.T) { + if got := nullableString(""); got != nil { + t.Errorf("empty should map to nil, got %v", got) + } + if got := nullableString(" "); got != nil { + t.Errorf("whitespace should map to nil, got %v", got) + } + if got := nullableString(" munich "); got != "munich" { + t.Errorf("expected trimmed 'munich', got %v", got) + } +} + +func TestNormaliseSliceAVisibilityAcceptsShared(t *testing.T) { + for _, v := range []string{"private", "firm", "shared"} { + if _, err := normaliseSliceAVisibility(v); err != nil { + t.Errorf("Slice-B visibility %q rejected: %v", v, err) + } + } + if _, err := normaliseSliceAVisibility("global"); !errors.Is(err, ErrInvalidInput) { + t.Errorf("'global' should be rejected as author-set, got %v", err) + } +} diff --git a/internal/services/checklist_template_service.go b/internal/services/checklist_template_service.go index 0bd896c..824028c 100644 --- a/internal/services/checklist_template_service.go +++ b/internal/services/checklist_template_service.go @@ -66,7 +66,10 @@ type UpdateTemplateInput struct { var ( validRegimes = map[string]bool{"UPC": true, "DE": true, "EPA": true, "OTHER": true} validLangs = map[string]bool{"de": true, "en": true} - validVisibilities = map[string]bool{"private": true, "firm": true} + // Author-settable visibilities. 'shared' is implicit (set + // automatically when the first checklist_shares row exists); 'global' + // is admin-only via ChecklistPromotionService. + validVisibilities = map[string]bool{"private": true, "firm": true, "shared": true} titleMaxLen = 200 descriptionMaxLen = 2000 freeTextMaxLen = 200 @@ -515,7 +518,7 @@ func normaliseSliceAVisibility(v string) (string, error) { x = "private" } if !validVisibilities[x] { - return "", fmt.Errorf("%w: visibility must be private | firm in Slice A, got %q", ErrInvalidInput, v) + return "", fmt.Errorf("%w: visibility must be private | firm | shared, got %q (global is admin-only)", ErrInvalidInput, v) } return x, nil } diff --git a/internal/services/checklist_template_service_test.go b/internal/services/checklist_template_service_test.go index 8bef94a..e66ad3f 100644 --- a/internal/services/checklist_template_service_test.go +++ b/internal/services/checklist_template_service_test.go @@ -48,12 +48,15 @@ func TestNormaliseLang(t *testing.T) { } func TestNormaliseSliceAVisibility(t *testing.T) { - for _, valid := range []string{"private", "firm", " ", ""} { + // Slice B opened up 'shared' as a valid author-set visibility + // (alongside 'private' and 'firm'). 'global' stays admin-only via + // ChecklistPromotionService. + for _, valid := range []string{"private", "firm", "shared", " ", ""} { if _, err := normaliseSliceAVisibility(valid); err != nil { t.Errorf("visibility(%q) errored: %v", valid, err) } } - for _, bad := range []string{"shared", "global", "public"} { + for _, bad := range []string{"global", "public"} { if _, err := normaliseSliceAVisibility(bad); !errors.Is(err, ErrInvalidInput) { t.Errorf("visibility(%q) expected ErrInvalidInput, got %v", bad, err) }