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 | 2178e7298d0e5d9af83afa12402fd587f0ba6ed8 |
| Branch | feature/aioe-upgrade |
| Path or archive member | internal/platform/security/tools.go |
| Original SHA-256 | 2468fc5a98225b0d13976ce65968cd9331a955fff468546e6e44eb6c5843b6e8 |
| Git blob SHA | 0458ac71a3634b34c9841e05d87c1b345f6b8be1 |
| Reading-copy SHA-256 | 2468fc5a98225b0d13976ce65968cd9331a955fff468546e6e44eb6c5843b6e8 |
Recovered source transcript
Download complete source text ↓168 lines · 7.4 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
}
// HasTool reports whether a principal's granted set includes a tool. Superuser is NOT special cased
// here, deliberately: entitlement is data, and a superuser whose grants were narrowed should see the
// narrowing rather than silently override it. The service grants a superuser every tool at creation, so
// the common case is covered by the data being right rather than by an exception in the check.
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"}}`))
})
}
}