feat(t-paliad-148) commit 2/6: ApprovalService + DerivationService — tuple-with-gate ladder

Rewires the 4 SQL ladder sites in approval_service.go (canApprove,
hasQualifiedApprover, ListPendingForApprover, PendingCountForUser) to read
the new tuple: project_teams.responsibility ∈ {lead, member} AND
users.profession at or above the threshold. observer/external rows close
the gate even if the user's profession would otherwise qualify — that's
the project-level call.

approval_levels.go renamed levelOf → professionLevel and added
responsibilityOpensGate helper. New constants: ProfessionPartner /
ProfessionOfCounsel / … and ResponsibilityLead / ResponsibilityMember /
ResponsibilityObserver / ResponsibilityExternal. New validators
IsValidProfession + IsValidResponsibility. RoleSeniorPA kept as legacy
alias for the one remaining call site that hasn't migrated yet.

CRITICAL trap pinned by TestProfessionLevel_NilIsZero: NULL profession
returns 0, never silently defaults to associate. External collaborators
must stay ineligible.

derivation_service.go: requireWritePermission switches from pt.role='lead'
to pt.responsibility='lead' — project-management writes gate on the
project responsibility, not the firm tier. EffectiveProjectRole replaced
by UserProjectAuthorityLevel (thin wrapper over the SQL function in
migration 057). The legacy method was unused dead code despite t-139
design intent.

Tests extended: profession ladder, responsibility gate, NULL trap,
new validators. Build + vet clean.
This commit is contained in:
m
2026-05-07 21:44:14 +02:00
parent ab2530ff44
commit 6506864730
4 changed files with 249 additions and 165 deletions

View File

@@ -2,18 +2,39 @@ package services
import "errors"
// Strict-ladder level helper for the 4-Augen-Prüfung approval gate
// (t-paliad-138). Mirrors paliad.approval_role_level(text) in migration
// 054. A user with project_teams.role R can approve any request whose
// required_role has level <= levelOf(R). Roles outside the approval
// ladder (local_counsel, expert, observer, anything new) return 0 and
// are ineligible to approve at any level.
// Strict-ladder helpers for the 4-Augen-Prüfung approval gate. The ladder
// drives both the t-paliad-138 single-value `required_role` policy
// grammar and the t-paliad-148 (profession, responsibility) tuple-with-
// gate evaluation in paliad.user_project_authority_level().
//
// The ladder values match paliad.approval_role_level(text) in migration
// 057. Higher level always satisfies lower; level 0 means ineligible to
// approve at any level.
// RoleSeniorPA is the new project_teams.role value added by migration 054.
// It sits between associate (3) and pa (1) and gives a named tier between
// "associate" and "PA" for projects that want PAs supervised by senior PAs
// rather than by associates.
const RoleSeniorPA = "senior_pa"
// Profession values on paliad.users.profession. Drive the ladder. NULL is
// represented as the empty string in Go (`*string` nil) — the ladder
// returns 0 for unknown values, including empty.
const (
ProfessionPartner = "partner"
ProfessionOfCounsel = "of_counsel"
ProfessionAssociate = "associate"
ProfessionSeniorPA = "senior_pa"
ProfessionPA = "pa"
ProfessionParalegal = "paralegal"
)
// Project-level responsibility values on paliad.project_teams.responsibility.
// Open the ladder gate (lead/member) or close it (observer/external).
const (
ResponsibilityLead = "lead"
ResponsibilityMember = "member"
ResponsibilityObserver = "observer"
ResponsibilityExternal = "external"
)
// RoleSeniorPA — kept as the legacy constant from t-paliad-138 for any
// remaining reference site that hasn't migrated. Equal to ProfessionSeniorPA.
const RoleSeniorPA = ProfessionSeniorPA
// EntityType values for the polymorphic approval workflow.
const (
@@ -47,47 +68,85 @@ const (
RequestStatusSuperseded = "superseded"
)
// DecisionKind discriminates "peer" (normal in-team sign-off) from
// "admin_override" (global_admin used the escape-hatch path) and
// "derived_peer" (a partner-unit-derived member with authority signed off
// DecisionKind discriminates 'peer' (normal in-team sign-off) from
// 'admin_override' (global_admin used the escape-hatch path) and
// 'derived_peer' (a partner-unit-derived member with authority signed off
// — added by t-paliad-139 / migration 055). Verlauf chronology renders
// these distinctly.
const (
DecisionKindPeer = "peer"
DecisionKindPeer = "peer"
DecisionKindAdminOverride = "admin_override"
DecisionKindDerivedPeer = "derived_peer"
DecisionKindDerivedPeer = "derived_peer"
)
// levelOf maps a project_teams.role value to its strict-ladder level.
// Mirrors paliad.approval_role_level(text) in SQL.
// professionLevel maps a profession value to its strict-ladder level.
// Mirrors paliad.approval_role_level(text). NULL profession (empty
// string) returns 0 — explicit so the trap is visible.
//
// 5: lead partner-tier on this project
// 5: partnerfirm-tier ceiling (replaces legacy 'lead')
// 4: of_counsel
// 3: associate ← default required level on new policies
// 2: senior_pa — added by migration 054
// 2: senior_pa
// 1: pa
// 0: local_counsel / expert / observer / anything new — ineligible to approve
func levelOf(role string) int {
switch role {
case "lead":
// 0: paralegal / "" / unknown — ineligible to approve
//
// CRITICAL: do not silently default NULL/empty to 'associate'. NULL
// profession means "no firm tier", which is the explicit signal that
// the user (e.g. external local counsel) cannot satisfy any tier.
// Test: TestProfessionLevel_NilIsZero pins this behaviour.
func professionLevel(profession string) int {
switch profession {
case ProfessionPartner:
return 5
case "of_counsel":
case ProfessionOfCounsel:
return 4
case "associate":
case ProfessionAssociate:
return 3
case RoleSeniorPA:
case ProfessionSeniorPA:
return 2
case "pa":
case ProfessionPA:
return 1
default:
return 0
}
}
// responsibilityOpensGate returns true iff the project responsibility
// opens the approval gate. Mirrors the SQL predicate
// `pt.responsibility IN ('lead','member')` used by
// paliad.user_project_authority_level().
func responsibilityOpensGate(responsibility string) bool {
return responsibility == ResponsibilityLead || responsibility == ResponsibilityMember
}
// IsValidRequiredRole returns true iff the role can be set as a policy's
// required_role (i.e. it has a non-zero strict-ladder level).
// required_role (i.e. it has a non-zero strict-ladder level). Used by
// the policy-authoring page to validate the dropdown value.
func IsValidRequiredRole(role string) bool {
return levelOf(role) > 0
return professionLevel(role) > 0
}
// IsValidProfession returns true iff the value is one of the recognised
// profession enum values. Empty string is intentionally rejected — the
// service layer represents NULL as a *string nil, not as "".
func IsValidProfession(p string) bool {
switch p {
case ProfessionPartner, ProfessionOfCounsel, ProfessionAssociate,
ProfessionSeniorPA, ProfessionPA, ProfessionParalegal:
return true
}
return false
}
// IsValidResponsibility returns true iff the value is one of the
// recognised project-responsibility enum values. Used by TeamService.
func IsValidResponsibility(r string) bool {
switch r {
case ResponsibilityLead, ResponsibilityMember,
ResponsibilityObserver, ResponsibilityExternal:
return true
}
return false
}
// Approval-flow errors. Handlers map these to the right HTTP status:

View File

@@ -83,14 +83,19 @@ func (s *ApprovalService) LookupPolicy(ctx context.Context, tx *sqlx.Tx, project
}
// hasQualifiedApprover counts users on the project's team-membership path
// (direct OR ancestor) whose role meets the strict-ladder threshold for
// requiredRole, plus any global_admin user, plus any partner-unit-derived
// member where the attachment grants authority (t-paliad-139). Excludes
// requesterID.
// (direct OR ancestor) whose (profession, responsibility) tuple meets the
// strict-ladder threshold, plus any global_admin user, plus any partner-
// unit-derived member where the attachment grants authority (t-paliad-139).
// Excludes requesterID.
//
// Returns true if at least one such user exists. The path-walk JOIN matches
// the visibility predicate so an ancestor lead qualifies for a descendant's
// approval, just like they have visibility.
// the visibility predicate so an ancestor partner qualifies for a
// descendant's approval, just like they have visibility.
//
// t-paliad-148: peer authority requires BOTH a profession with sufficient
// level AND a responsibility ∈ {lead, member} that opens the gate.
// observer/external rows are excluded even if the user's profession would
// otherwise qualify — that's the point of the project-level gate.
func (s *ApprovalService) hasQualifiedApprover(ctx context.Context, tx *sqlx.Tx, projectID, requesterID uuid.UUID, requiredRole string) (bool, error) {
q := `WITH path AS (
SELECT string_to_array(p.path, '.')::uuid[] AS ids
@@ -98,9 +103,11 @@ func (s *ApprovalService) hasQualifiedApprover(ctx context.Context, tx *sqlx.Tx,
)
SELECT EXISTS (
SELECT 1 FROM paliad.project_teams pt
JOIN paliad.users u ON u.id = pt.user_id
JOIN path ON pt.project_id = ANY(path.ids)
WHERE pt.user_id <> $2
AND paliad.approval_role_level(pt.role) >= paliad.approval_role_level($3)
AND pt.responsibility IN ('lead', 'member')
AND paliad.approval_role_level(u.profession) >= paliad.approval_role_level($3)
UNION ALL
SELECT 1 FROM paliad.users u
WHERE u.global_role = 'global_admin' AND u.id <> $2
@@ -402,13 +409,17 @@ func (s *ApprovalService) canApprove(ctx context.Context, tx *sqlx.Tx, callerID
if user.GlobalRole == "global_admin" {
return DecisionKindAdminOverride, nil
}
// Path-walk: check direct OR ancestor team membership with sufficient role.
// Path-walk: check direct OR ancestor team membership with a
// responsibility that opens the gate (lead/member) AND a profession
// whose level meets the threshold (t-paliad-148 tuple-with-gate).
q := `SELECT EXISTS (
SELECT 1 FROM paliad.project_teams pt
JOIN paliad.users u ON u.id = pt.user_id
WHERE pt.user_id = $1
AND pt.project_id = ANY(string_to_array(
(SELECT path FROM paliad.projects WHERE id = $2), '.')::uuid[])
AND paliad.approval_role_level(pt.role) >= paliad.approval_role_level($3)
AND pt.responsibility IN ('lead', 'member')
AND paliad.approval_role_level(u.profession) >= paliad.approval_role_level($3)
)`
var ok bool
if err := tx.GetContext(ctx, &ok, q, callerID, req.ProjectID, req.RequiredRole); err != nil {
@@ -739,16 +750,20 @@ func (s *ApprovalService) ListPendingForApprover(ctx context.Context, callerID u
"ar.requested_by <> $1",
// Eligibility (any one branch suffices):
// - caller is global_admin, OR
// - caller has direct/ancestor project_teams role meeting the threshold, OR
// - caller has direct/ancestor project_teams membership with
// responsibility ∈ {lead, member} AND profession at or above
// the threshold (t-paliad-148 tuple-with-gate), OR
// - caller is a partner-unit-derived member with derive_grants_authority=true
// on an attachment in the project's path, and the unit_role maps to a
// project_role at or above the threshold (t-paliad-139).
// profession at or above the threshold (t-paliad-139).
`(EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $1 AND u.global_role = 'global_admin')
OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
JOIN paliad.users u ON u.id = pt.user_id
WHERE pt.user_id = $1
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND paliad.approval_role_level(pt.role) >= paliad.approval_role_level(ar.required_role)
AND pt.responsibility IN ('lead', 'member')
AND paliad.approval_role_level(u.profession) >= paliad.approval_role_level(ar.required_role)
)
OR EXISTS (
SELECT 1 FROM paliad.project_partner_units ppu
@@ -838,7 +853,8 @@ func (s *ApprovalService) GetRequest(ctx context.Context, requestID uuid.UUID) (
// Cheap query for the sidebar bell badge.
//
// Eligibility mirrors ListPendingForApprover: global_admin OR direct/
// ancestor project_teams role meeting the threshold OR partner-unit-
// ancestor project_teams membership with responsibility ∈ {lead, member}
// AND profession meeting the threshold (t-paliad-148) OR partner-unit-
// derived authority (t-paliad-139).
func (s *ApprovalService) PendingCountForUser(ctx context.Context, callerID uuid.UUID) (int, error) {
q := `SELECT COUNT(*)
@@ -849,9 +865,11 @@ func (s *ApprovalService) PendingCountForUser(ctx context.Context, callerID uuid
AND (EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $1 AND u.global_role = 'global_admin')
OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
JOIN paliad.users u ON u.id = pt.user_id
WHERE pt.user_id = $1
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND paliad.approval_role_level(pt.role) >= paliad.approval_role_level(ar.required_role)
AND pt.responsibility IN ('lead', 'member')
AND paliad.approval_role_level(u.profession) >= paliad.approval_role_level(ar.required_role)
)
OR EXISTS (
SELECT 1 FROM paliad.project_partner_units ppu

View File

@@ -2,7 +2,8 @@ package services
// Approval-service tests. Two layers:
//
// - Pure-Go: levelOf strict ladder + IsValidRequiredRole. No DB touch.
// - Pure-Go: professionLevel strict ladder + IsValidRequiredRole +
// responsibilityOpensGate (t-paliad-148). No DB touch.
// - Live-DB: the full submit→approve and submit→reject flows on real
// paliad.deadlines / paliad.approval_requests rows. Skipped when
// TEST_DATABASE_URL is unset, mirroring audit_service_test and
@@ -26,62 +27,105 @@ import (
// Pure-Go tests.
// ============================================================================
func TestLevelOf_StrictLadder(t *testing.T) {
func TestProfessionLevel_StrictLadder(t *testing.T) {
cases := []struct {
role string
want int
profession string
want int
}{
{"lead", 5},
{"partner", 5},
{"of_counsel", 4},
{"associate", 3},
{"senior_pa", 2},
{"pa", 1},
{"paralegal", 0},
{"", 0},
{"unknown", 0},
// Legacy values that pre-dated the t-paliad-148 split must NOT
// satisfy the ladder. The SQL helper still recognises 'lead' as a
// deprecated-shadow row until migration 058; the Go helper does
// not — call sites have all migrated to read users.profession.
{"lead", 0},
{"local_counsel", 0},
{"expert", 0},
{"observer", 0},
{"", 0},
{"unknown", 0},
}
for _, c := range cases {
t.Run(c.role, func(t *testing.T) {
if got := levelOf(c.role); got != c.want {
t.Errorf("levelOf(%q) = %d, want %d", c.role, got, c.want)
t.Run(c.profession, func(t *testing.T) {
if got := professionLevel(c.profession); got != c.want {
t.Errorf("professionLevel(%q) = %d, want %d", c.profession, got, c.want)
}
})
}
}
func TestLevelOf_HigherSatisfiesLower(t *testing.T) {
// "Anyone strictly above the required level satisfies it" — verify by
// asserting the ladder is monotonic and partner > all PA tiers etc.
if levelOf("lead") <= levelOf("associate") {
t.Errorf("lead must outrank associate")
func TestProfessionLevel_NilIsZero(t *testing.T) {
// CRITICAL trap pin: NULL profession (empty string in Go) returns 0,
// not "default to associate" or anything similar. This is what gates
// external collaborators (local_counsel, expert) out of the approval
// ladder when their project responsibility is set to 'external' but
// their users.profession is also set to a real tier by mistake.
if got := professionLevel(""); got != 0 {
t.Errorf("professionLevel(\"\") must be 0, got %d — NULL profession is ineligible", got)
}
if levelOf("associate") <= levelOf("senior_pa") {
}
func TestProfessionLevel_HigherSatisfiesLower(t *testing.T) {
// "Anyone strictly above the required level satisfies it" — verify by
// asserting the ladder is monotonic.
if professionLevel("partner") <= professionLevel("associate") {
t.Errorf("partner must outrank associate")
}
if professionLevel("associate") <= professionLevel("senior_pa") {
t.Errorf("associate must outrank senior_pa")
}
if levelOf("senior_pa") <= levelOf("pa") {
if professionLevel("senior_pa") <= professionLevel("pa") {
t.Errorf("senior_pa must outrank pa")
}
if levelOf("of_counsel") <= levelOf("associate") {
if professionLevel("of_counsel") <= professionLevel("associate") {
t.Errorf("of_counsel must outrank associate")
}
// PA-required policy: anyone associate-or-above must satisfy.
if levelOf("associate") < levelOf("pa") {
if professionLevel("associate") < professionLevel("pa") {
t.Errorf("associate must satisfy a pa-required policy")
}
}
func TestResponsibilityOpensGate(t *testing.T) {
cases := []struct {
responsibility string
open bool
}{
{"lead", true},
{"member", true},
{"observer", false},
{"external", false},
{"", false},
{"unknown", false},
}
for _, c := range cases {
t.Run(c.responsibility, func(t *testing.T) {
if got := responsibilityOpensGate(c.responsibility); got != c.open {
t.Errorf("responsibilityOpensGate(%q) = %v, want %v",
c.responsibility, got, c.open)
}
})
}
}
func TestIsValidRequiredRole(t *testing.T) {
cases := []struct {
role string
ok bool
}{
{"lead", true},
{"partner", true},
{"of_counsel", true},
{"associate", true},
{"senior_pa", true},
{"pa", true},
{"paralegal", false},
// Legacy values that pre-dated the t-paliad-148 split must be
// rejected as policy targets.
{"lead", false},
{"local_counsel", false},
{"expert", false},
{"observer", false},
@@ -96,6 +140,40 @@ func TestIsValidRequiredRole(t *testing.T) {
}
}
func TestIsValidProfession(t *testing.T) {
for _, p := range []string{"partner", "of_counsel", "associate", "senior_pa", "pa", "paralegal"} {
t.Run(p, func(t *testing.T) {
if !IsValidProfession(p) {
t.Errorf("IsValidProfession(%q) must be true", p)
}
})
}
for _, p := range []string{"", "lead", "junior_associate", "trainee", "unknown"} {
t.Run("invalid_"+p, func(t *testing.T) {
if IsValidProfession(p) {
t.Errorf("IsValidProfession(%q) must be false", p)
}
})
}
}
func TestIsValidResponsibility(t *testing.T) {
for _, r := range []string{"lead", "member", "observer", "external"} {
t.Run(r, func(t *testing.T) {
if !IsValidResponsibility(r) {
t.Errorf("IsValidResponsibility(%q) must be true", r)
}
})
}
for _, r := range []string{"", "associate", "lead2", "unknown"} {
t.Run("invalid_"+r, func(t *testing.T) {
if IsValidResponsibility(r) {
t.Errorf("IsValidResponsibility(%q) must be false", r)
}
})
}
}
func TestApprovalEventType(t *testing.T) {
cases := []struct {
entity, step, want string

View File

@@ -113,18 +113,23 @@ func (s *DerivationService) requireWritePermission(ctx context.Context, callerID
if user != nil && user.GlobalRole == "global_admin" {
return nil
}
var role string
err = s.db.GetContext(ctx, &role,
`SELECT role FROM paliad.project_teams
// t-paliad-148: project-management write permission gates on the
// project responsibility, not on the (firm-tier) profession. A
// partner with responsibility=observer on this matter cannot manage
// partner-unit attachments here; conversely a non-partner with
// responsibility=lead can.
var responsibility string
err = s.db.GetContext(ctx, &responsibility,
`SELECT responsibility FROM paliad.project_teams
WHERE project_id = $1 AND user_id = $2`,
projectID, callerID)
if errors.Is(err, sql.ErrNoRows) {
return ErrForbidden
}
if err != nil {
return fmt.Errorf("read project_teams role: %w", err)
return fmt.Errorf("read project_teams responsibility: %w", err)
}
if role != RoleLead {
if responsibility != ResponsibilityLead {
return ErrForbidden
}
return nil
@@ -347,106 +352,30 @@ func (s *DerivationService) ListDescendantStaffed(ctx context.Context, callerID,
return rows, nil
}
// EffectiveProjectRole returns (role, source) where source is one of:
// 'direct', 'ancestor', 'descendant', 'derived'. Used by the t-138
// approval ladder via canApprove() — Phase 3 of t-paliad-139 will wire
// this in.
// UserProjectAuthorityLevel returns the effective approval-ladder level
// for user U on project P, evaluated as a tuple-with-gate:
//
// Resolution order:
// 1. direct (this project_teams row)
// 2. ancestor (project_teams on any ancestor — closest wins)
// 3. derived (partner_unit_members on an attached unit on this project
// or any ancestor — closest wins; only when derive_grants_authority=true)
// 4. descendant (rare for authority — explicit staffing on a descendant
// does NOT confer authority on the ancestor; returned for read use
// only, callers should prefer the higher tiers)
// profession_level = approval_role_level(U.profession) // 0 if NULL
// responsibility = direct or ancestor on project P
// gate_open = responsibility IN {lead, member}
// derived_role = approval_role_from_unit_role(unit_role) // when grants_authority
// level = max( profession_level if gate_open else 0,
// derived_role_level )
//
// Returns ("", "") when the user has no membership of any kind. This is a
// service-internal lookup — it does NOT visibility-check, since callers
// (the t-138 approval gate) need to know the caller's effective role even
// when visibility is being evaluated for the first time.
func (s *DerivationService) EffectiveProjectRole(ctx context.Context, userID, projectID uuid.UUID) (string, string, error) {
var path string
err := s.db.GetContext(ctx, &path,
`SELECT path FROM paliad.projects WHERE id = $1`, projectID)
// Thin wrapper over paliad.user_project_authority_level — kept here so
// any future caller that needs the level without writing raw SQL has a
// single helper to call. The ApprovalService SQL paths inline the
// computation directly for query efficiency.
func (s *DerivationService) UserProjectAuthorityLevel(ctx context.Context, userID, projectID uuid.UUID) (int, error) {
var lvl int
err := s.db.GetContext(ctx, &lvl,
`SELECT paliad.user_project_authority_level($1, $2)`,
userID, projectID)
if errors.Is(err, sql.ErrNoRows) {
return "", "", nil
return 0, nil
}
if err != nil {
return "", "", fmt.Errorf("read project path: %w", err)
return 0, fmt.Errorf("read user project authority level: %w", err)
}
ancestorIDs := pathToIDStrings(path)
// 1. Direct
var directRole string
err = s.db.GetContext(ctx, &directRole,
`SELECT role FROM paliad.project_teams WHERE project_id = $1 AND user_id = $2`,
projectID, userID)
if err == nil {
return directRole, "direct", nil
}
if !errors.Is(err, sql.ErrNoRows) {
return "", "", fmt.Errorf("read direct role: %w", err)
}
// 2. Ancestor (closest wins via path distance — already root→self order
// in the path; pick the row whose project_id appears latest in the
// ancestorIDs array).
type ancRow struct {
Role string `db:"role"`
ProjID string `db:"project_id"`
Position int `db:"position"`
}
var ancestorMatches []ancRow
if len(ancestorIDs) > 0 {
err = s.db.SelectContext(ctx, &ancestorMatches, `
SELECT pt.role,
pt.project_id::text AS project_id,
array_position($1::uuid[], pt.project_id) AS position
FROM paliad.project_teams pt
WHERE pt.user_id = $2
AND pt.project_id = ANY($1::uuid[])
ORDER BY position DESC NULLS LAST
LIMIT 1`,
pq.StringArray(ancestorIDs), userID)
if err != nil {
return "", "", fmt.Errorf("read ancestor role: %w", err)
}
if len(ancestorMatches) > 0 {
return ancestorMatches[0].Role, "ancestor", nil
}
}
// 3. Derived with authority. Only authority-granting attachments count
// here; visibility-only derivation does not yield an effective role for
// approval purposes. The derived role is mapped from unit_role via
// approval_role_from_unit_role (a SQL function added in migration 055).
type derivedRow struct {
Role string `db:"role"`
}
var derived []derivedRow
if len(ancestorIDs) > 0 {
err = s.db.SelectContext(ctx, &derived, `
SELECT paliad.approval_role_from_unit_role(pum.unit_role) AS role
FROM paliad.project_partner_units ppu
JOIN paliad.partner_unit_members pum
ON pum.partner_unit_id = ppu.partner_unit_id
AND pum.user_id = $2
AND pum.unit_role = ANY(ppu.derive_unit_roles)
WHERE ppu.project_id = ANY($1::uuid[])
AND ppu.derive_grants_authority = true
ORDER BY paliad.approval_role_level(
paliad.approval_role_from_unit_role(pum.unit_role)
) DESC
LIMIT 1`,
pq.StringArray(ancestorIDs), userID)
if err != nil {
return "", "", fmt.Errorf("read derived role: %w", err)
}
if len(derived) > 0 {
return derived[0].Role, "derived", nil
}
}
return "", "", nil
return lvl, nil
}