factor out kjol auth

This commit is contained in:
2026-07-14 16:49:56 -04:00
parent 7d7b7354df
commit 023599a41b
7 changed files with 1930 additions and 2 deletions

153
go/auth/authz.go Normal file
View File

@@ -0,0 +1,153 @@
package auth
import (
"net/http"
"time"
. "kjol/basic"
"kjol/l4g"
)
// Permissions are plain strings, aggregated per request by the app's
// PermissionResolver into a single map. The engine does not care where they come
// from — a user's direct grants, the groups they belong to, an organization
// role, a claim snapshot from an SSO login — only whether the map contains what
// a route requires.
//
// Two families of check live here, and the difference matters:
//
// - Authorized / AuthorizedExact / AuthorizedAny are pure predicates, for
// display logic. They are expected to fail routinely (most users are not
// admins, and every "admin only" menu asks on every page), so they log
// nothing.
//
// - Require / RequireExact / RequireAny are middleware. A failure here means a
// request actually reached a route the caller may not have, so it is logged.
//
// The pairing is deliberate: gate the link with Authorized so the request is
// never made, and gate the route with Require so it cannot be made anyway.
// Authorized reports whether the principal holds every listed permission.
// The super permission satisfies any requirement.
func (a *Authenticator) Authorized(p Principal, requirements ...string) bool {
if a.hasSuper(p) {
return true
}
return holdsAll(p, requirements)
}
// AuthorizedExact reports whether the principal holds every listed permission,
// with no super-permission bypass.
//
// Use it where the question is "does this user really have X" rather than "may
// this user do X" — e.g. showing an admin their own effective grants, where the
// wildcard would otherwise make every box look ticked.
func (a *Authenticator) AuthorizedExact(p Principal, requirements ...string) bool {
return holdsAll(p, requirements)
}
// AuthorizedAny reports whether the principal holds at least one of the listed
// permissions. The super permission satisfies it.
//
// This is for resources read by several pages whose own permissions differ — a
// product-options list fetched by both the inventory editor and the sales
// terminal — where requiring all of them would lock out callers who legitimately
// hold only one.
func (a *Authenticator) AuthorizedAny(p Principal, anyOf ...string) bool {
if a.hasSuper(p) {
return true
}
for _, requirement := range anyOf {
if p.Permissions[requirement] {
return true
}
}
return false
}
// Require gates a handler on the caller holding every listed permission,
// answering 403 otherwise. Mount it inside LoadContext.
func (a *Authenticator) Require(h http.HandlerFunc, requirements ...string) http.HandlerFunc {
return a.gate(h, func(p Principal) bool { return a.Authorized(p, requirements...) }, requirements)
}
// RequireExact gates a handler on the caller holding every listed permission,
// with no super-permission bypass.
func (a *Authenticator) RequireExact(h http.HandlerFunc, requirements ...string) http.HandlerFunc {
return a.gate(h, func(p Principal) bool { return a.AuthorizedExact(p, requirements...) }, requirements)
}
// RequireAny gates a handler on the caller holding at least one of the listed
// permissions.
func (a *Authenticator) RequireAny(h http.HandlerFunc, anyOf ...string) http.HandlerFunc {
return a.gate(h, func(p Principal) bool { return a.AuthorizedAny(p, anyOf...) }, anyOf)
}
// gate is the shared middleware body: check, log-and-deny, or serve.
func (a *Authenticator) gate(h http.HandlerFunc, allow func(Principal) bool, requirements []string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r)
if !allow(p) {
a.logDenial(r, p, requirements)
if isAPIRequest(r) {
respondJSONError(w, http.StatusForbidden, "Forbidden: insufficient permissions")
return
}
w.WriteHeader(http.StatusForbidden)
return
}
h(w, r)
}
}
// logDenial records a failed route authorization.
//
// Unlike a failed display check, this means a request was actually issued for a
// route the caller cannot have: either a user is missing access they need, or
// someone is probing. Both are worth seeing, so this is the one authorization
// path that logs.
func (a *Authenticator) logDenial(r *http.Request, p Principal, requirements []string) {
entry := l4g.Entry{
Category: l4g.CATEGORY_AUTH,
LogType: l4g.TYPE_WARN,
Content: MakePtr("Failed Endpoint Authorization"),
StructuredContent: l4g.Serialize(struct {
Route string
RequiredPermissions []string
CurrentPermissions map[string]bool
Authenticated bool
}{
Route: r.URL.Path,
RequiredPermissions: requirements,
CurrentPermissions: p.Permissions,
Authenticated: p.Authenticated,
}),
Timestamp: time.Now(),
}
// Anonymous denials carry no user to attribute the entry to.
if p.Authenticated {
entry.AppUserID = MakePtr(p.Session.UserID)
entry.OrgID = p.Session.OrgID
entry.IdentityID = MakePtr(p.Session.ID)
}
l4g.Write(entry)
}
// hasSuper reports whether the principal holds the app's wildcard permission.
func (a *Authenticator) hasSuper(p Principal) bool {
return a.cfg.SuperPermission != "" && p.Permissions[a.cfg.SuperPermission]
}
// holdsAll reports whether the principal holds every listed permission.
func holdsAll(p Principal, requirements []string) bool {
for _, requirement := range requirements {
if !p.Permissions[requirement] {
return false
}
}
return true
}