Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/platform/security/tools.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/platform/security/tools.go |
| Original SHA-256 | 4633f3bfe95d11025ec428fdd4c706f80fcf9c71e07daf813d2231027bec5d55 |
| Git blob SHA | 0131b01e216d579c8ce3eb9b7c6d8a2857a2ccd8 |
| Reading-copy SHA-256 | 4633f3bfe95d11025ec428fdd4c706f80fcf9c71e07daf813d2231027bec5d55 |
Recovered source transcript
Download complete source text ↓242 lines · 11.1 KB
package security
import (
"net/http"
"sort"
"github.com/missionworkforce/missionmatch/internal/platform/tenant"
)
// Tool entitlement: what a person may SEE, kept separate from what they may DO.
//
// Role answers authority: may this person edit a requirement, run an export, disable an account. Tool
// answers surface: does recruitMATCH appear for them at all. The two are genuinely independent, and
// collapsing them would be wrong in both directions. One client's recruiter works only recruitMATCH and
// slateMATCH while another's sees every product, and that is not a difference in authority, so it must
// not be expressed by giving one of them a different role. Equally, hiding a tool must never be mistaken
// for removing authority: a user with recruitMATCH hidden but CapCandidateWrite still held is refused by
// the tool gate, not by the capability check, and the refusal has to be the tool's.
//
// Entitlement is per user and explicit. Absence of a grant is absence of access, in the API and in the
// interface, so an account created without grants sees nothing rather than everything. The opposite
// default fails toward silent over exposure of a whole product to someone who was never meant to have it.
// Tool is one of the four products. The vocabulary is closed and matches the CHECK constraint on
// user_tool_access, so a value that would be refused by the database is refused here first.
type Tool string
const (
ToolMissionMatch Tool = "missionmatch"
ToolScreenMatch Tool = "screenmatch"
ToolSlateMatch Tool = "slatematch"
ToolRecruitMatch Tool = "recruitmatch"
)
// AllTools is the full vocabulary, in the order the navigation presents them.
var AllTools = []Tool{ToolMissionMatch, ToolScreenMatch, ToolSlateMatch, ToolRecruitMatch}
// ValidTool reports whether a string names a real tool. Used before any write, so an unknown value is a
// 400 from the service rather than a constraint violation surfacing as a 500.
func ValidTool(s string) bool {
for _, t := range AllTools {
if Tool(s) == t {
return true
}
}
return false
}
// ToolsOf normalizes a raw list into a sorted, deduplicated, valid set. Unknown values are DROPPED
// rather than causing a failure, because this runs on the read path when turning stored rows into a
// principal: a tool retired from the vocabulary while still present in old rows must degrade to that
// tool being invisible, not to the user being unable to log in.
func ToolsOf(raw []string) []string {
seen := make(map[string]bool, len(raw))
out := make([]string, 0, len(raw))
for _, s := range raw {
if !ValidTool(s) || seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
sort.Strings(out)
return out
}
// The by rule tool resolution, amendment 6 decision 12, ratified 28 July 2026.
//
// This replaces an earlier assumption that proved false in production. The old comment on HasTool said
// superuser needed no special case because "the service grants a superuser every tool at creation, so the
// common case is covered by the data being right". The data was NOT right: the design partner's tenant top
// seat held role superuser with ZERO tool grants, so every checkbox in the entitlement grid rendered locked
// and the product looked broken. Relying on provisioning to keep an invariant true is not the same as the
// invariant being true.
//
// So the top seats now hold tools BY RULE, the same way superuser holds capabilities by rule:
//
// operator every tool, in every tenant, because it is the platform plane
// superuser every tool its tenant is entitled to, because it is that tenant's top seat
//
// Every role BELOW superuser is unchanged and still grant driven, because confer only what you hold is the
// ceiling that stops an actor widening their own access through an account they control. That ceiling is
// load bearing exactly where access is spread, and it is meaningless at a seat that already holds
// everything.
//
// NOTE on "entitled to": there is no organization level product entitlement table yet, so today a tenant is
// treated as entitled to all four tools. Amendment 6 Section 2 item 10 puts per tool product entitlements in
// the Phase 5A operator console; when that lands, this function narrows the superuser set to the entitled
// set and nothing below superuser changes.
func EffectiveTools(role Role, granted []string) []string {
if role == RoleOperator || role == RoleSuperuser {
out := make([]string, 0, len(AllTools))
for _, t := range AllTools {
out = append(out, string(t))
}
return ToolsOf(out)
}
return ToolsOf(granted)
}
// OrgEntitledTools returns the products a TENANT has bought, which is a different question from what any
// one of its users holds. It is the org side of amendment 6 decision 13: a control disabled because the
// organization never bought the product is a different fact, told differently, from one disabled because
// the actor personally does not hold it.
//
// There is no organization level product entitlement table in the schema yet, so every tenant is entitled
// to all four today. That is stated here, in one named function, rather than assumed at each call site,
// because it is a temporary answer: amendment 6 section 2 item 10 puts per tool entitlements in the Phase
// 5A operator console, and when that lands this function reads them and NOTHING else changes. The orgID
// parameter is deliberately taken now, unused, so adding the lookup is a body change rather than a
// signature change rippling through every caller.
func OrgEntitledTools(orgID string) []string {
_ = orgID
out := make([]string, 0, len(AllTools))
for _, t := range AllTools {
out = append(out, string(t))
}
return ToolsOf(out)
}
// The two reasons a tool control may be disabled, amendment 6 decision 13, NO SILENT GREY. A control that
// is merely greyed with no explanation reads as a broken product, which is how this defect reached Joseph.
const (
ReasonOrgNotEntitled = "Your organization is not entitled to this product."
ReasonActorLacksTool = "You do not hold this tool, and you can only grant tools you hold."
)
// ToolDisabledReason returns why a tool control is disabled, or the empty string when it is enabled. The
// organization check comes first because it is the stronger fact: if the tenant never bought the product,
// whether the actor holds it is beside the point.
func ToolDisabledReason(tool Tool, orgEntitled, actorTools []string) string {
if !HasTool(orgEntitled, tool) {
return ReasonOrgNotEntitled
}
if !HasTool(actorTools, tool) {
return ReasonActorLacksTool
}
return ""
}
// HasTool reports whether a granted set includes a tool. It is a pure set membership check and stays that
// way: the by rule widening happens once, in EffectiveTools, at the point the principal is built, so every
// downstream consumer (RequireTool, the API payload, the frontend grid) sees ONE consistent answer rather
// than each re deriving the rule.
func HasTool(granted []string, want Tool) bool {
for _, g := range granted {
if Tool(g) == want {
return true
}
}
return false
}
// CanGrantTools reports whether an actor may change another user's tool entitlement, and is the tool
// analogue of CanGrantRole. Two conditions, both required:
//
// 1. The actor must outrank the target. Same separation of duties as roles: nobody edits a peer's or a
// superior's access, and nobody edits their own.
// 2. The actor may only grant tools they themselves hold. Without this an admin entitled to two tools
// could grant themselves a third by way of an account they control, so the ceiling on what an actor
// can spread is what they already have.
//
// Revocation is bounded by the first condition only: an actor may remove any tool from someone they
// outrank, including one the actor does not hold, because taking access away is the safe direction.
func CanGrantTools(actorRole, targetRole Role, actorTools, wantTools []string) bool {
if !OutRanks(actorRole, targetRole) {
return false
}
return CanConferTools(actorTools, wantTools)
}
// CanConferTools is the POSSESSION half on its own: may this actor hand out these tools at all, setting
// aside who the recipient is.
//
// It exists because the two halves answer to different authorities and one caller only needs the second.
// On an INVITE, whether the actor may create an account at that role has already been decided by
// CanGrantRole, which permits the one case OutRanks does not: a superuser creating another superuser.
// Routing an invite through CanGrantTools would re impose strict outranking on top of that decision and
// refuse the tools for an account the actor was just authorized to create, so the invitation fails as a
// 403 with the role permitted and the entitlement denied. That is not a stricter policy, it is two rules
// disagreeing.
//
// Editing an EXISTING user still goes through CanGrantTools, because there the outranking condition is
// doing real work: it is what stops an actor rewriting a peer's or a superior's access.
func CanConferTools(actorTools, wantTools []string) bool {
for _, w := range wantTools {
if !ValidTool(w) {
return false
}
if !HasTool(actorTools, Tool(w)) {
return false
}
}
return true
}
// RequireTool is the API side gate for a product surface. It is the enforcement that matters: hiding a
// route in the navigation is a presentation choice, and a user who keeps a bookmark, or who reads the
// bundle, reaches the same handler. Mounting this on the tool's route group means the entitlement is
// enforced once, at the boundary, for every endpoint the tool owns including ones added later.
//
// It refuses with 404 rather than 403. A tool the caller is not entitled to should not be distinguishable
// from a tool that does not exist: a 403 confirms the surface is real and tells an unentitled account
// exactly which products the organization has bought.
func RequireTool(tool Tool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := tenant.UserFromContext(r.Context())
if !ok || !HasTool(p.Tools, tool) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"not found"}}`))
return
}
next.ServeHTTP(w, r)
})
}
}
// RequireAnyTool gates a surface on holding AT LEAST ONE of the given tools. It exists for the shared
// readers (the candidate and requirement pickers) that every product needs: gating them to a single tool
// would break the other three, and leaving them ungated would expose the candidate list to an account
// entitled to nothing at all. Same 404 posture as RequireTool, for the same reason.
func RequireAnyTool(tools ...Tool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := tenant.UserFromContext(r.Context())
if ok {
for _, t := range tools {
if HasTool(p.Tools, t) {
next.ServeHTTP(w, r)
return
}
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"not found"}}`))
})
}
}