package orgsettings import ( "context" "github.com/jackc/pgx/v5" "github.com/missionworkforce/missionmatch/internal/platform/security" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // FIELD SENSITIVITY, RESOLVED FOR THE CALLER, AND THE ONE PLACE IT IS ENFORCED. // // security.CanSeeField is the pure decision. This is the thing that actually asks it: it reads the // organization's configured sensitivity, reads the caller's role from the validated session, and hands back // three plain booleans that a response assembler can act on without knowing anything about roles or settings. // // IT EXISTS BECAUSE THE SETTING WAS DECORATIVE. CanSeeField had exactly one caller, GET /org/policy, which // REPORTS the answers so an interface can hide a column. Nothing consulted it on a data path, so every // candidate search returned every email address and every placement returned its margin, to every role, no // matter what the owner had configured. An access control that only exists in the browser is not an access // control: the answer was one curl away, and the settings screen was making a promise the API did not keep. // // ONE RESOLVER RATHER THAN ONE PER MODULE, deliberately. Contacts live in matching and rates live in // recruiting, and two implementations of the same rule are two chances to get it different. This is the // seam both call. // Policy is what THIS caller may see, already decided. It carries no configuration: a consumer needs the // answer, not the setting, and handing round the setting invites somebody to re derive the answer badly. type Policy struct { CanSeeRates bool CanSeeMargins bool CanSeeContacts bool } // Unrestricted reports that nothing needs hiding, so a caller can skip a redaction walk entirely on the // common path where an owner or an admin is reading. func (p Policy) Unrestricted() bool { return p.CanSeeRates && p.CanSeeMargins && p.CanSeeContacts } // ResolvePolicyTx resolves the caller's policy inside an existing tenant transaction. // // A NIL STORE RESOLVES TO THE SHIPPED DEFAULTS, not to permit everything and not to deny everything. Permit // would reintroduce the hole this file closes. Deny would black out rates and contacts on a node that simply // was not built with a settings store, which is a working configuration and must not become an empty product. // The defaults are the same values the migration writes, so a node without a store behaves like a freshly // provisioned organization, which is the only answer that is both safe and true. // // AN UNREADABLE SETTINGS ROW ALSO FALLS TO THE DEFAULTS rather than returning an error, and that is the // deliberate choice: this is called from inside response assembly, where an error would turn a transient // database hiccup into a failed page load. Falling to defaults degrades toward the restrictive side, which is // the direction a failure in an access control is allowed to go. func (s *Store) ResolvePolicyTx(ctx context.Context, tx pgx.Tx) Policy { st := Defaults() if s != nil { if loaded, err := s.LoadTx(ctx, tx); err == nil { st = loaded } } return policyFor(ctx, st) } // ResolvePolicy resolves the caller's policy in its own tenant transaction, for a caller that does not // already hold one. func (s *Store) ResolvePolicy(ctx context.Context) Policy { st := Defaults() if s != nil { if loaded, err := s.Load(ctx); err == nil { st = loaded } } return policyFor(ctx, st) } // policyFor asks the pure decision three times against the caller's role. // // NO PRINCIPAL DENIES EVERYTHING. That path should be unreachable, because every route that reaches here is // behind the session resolver, but "should be unreachable" is not a reason to widen. An unauthenticated // caller who somehow arrives here sees a record with no rate, no margin and no contact details rather than // one with all three. func policyFor(ctx context.Context, st Settings) Policy { p, ok := tenant.UserFromContext(ctx) if !ok { return Policy{} } role := security.Role(p.Role) return Policy{ CanSeeRates: security.CanSeeField(role, st.RateVisibility), CanSeeMargins: security.CanSeeField(role, st.MarginVisibility), CanSeeContacts: security.CanSeeField(role, st.ContactVisibility), } }