154 lines
5.3 KiB
Go
154 lines
5.3 KiB
Go
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
|
|
}
|