Files
kandev/apps/backend/internal/auth/authn/identity.go
T
João Salavisa 481359ddb2 feat(auth): organization units, tenancy, roles and scopes for team access (#2919)
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 13:40:43 +00:00

129 lines
4.7 KiB
Go

// Package authn carries the per-request caller identity for the opt-in
// authentication feature. It is intentionally tiny and dependency-light so
// any package (HTTP handlers, WS gateway, boot-payload builders) can consume
// the identity without importing the auth service.
package authn
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
)
// Role is the coarse authorization level of a user.
type Role string
const (
// RoleAdmin unlocks user management and system-settings mutation. It does
// NOT grant visibility into other users' workspaces (hard privacy).
RoleAdmin Role = "admin"
// RoleMember is a regular authenticated user.
RoleMember Role = "member"
// RoleGuest holds no org scopes and reaches only workspaces it is an
// explicit member of, including none of the org-visible ones.
RoleGuest Role = "guest"
)
// GinContextKey is the gin-context key holding the request Identity.
const GinContextKey = "kandev_auth_identity"
type ctxKey struct{}
// Identity describes the authenticated caller of a request or WS connection.
// When authentication is disabled, the middleware injects a synthetic admin
// identity for the pre-auth single user so downstream code never branches on
// auth mode — only on identity.
type Identity struct {
UserID string
Role Role
// Synthetic marks the implicit single-user identity injected when
// authentication is disabled (preserves today's behavior exactly).
Synthetic bool
// SessionID is set when the identity came from a browser session cookie.
SessionID string
// TokenID is set when the identity came from a personal access token.
TokenID string
// OrgID is the caller's tenant. It comes from the user record and from
// nowhere else: no route, payload, header or WS frame may name an org, so
// the tenant is a total function of the authenticated identity and no
// request can move a caller between tenants.
OrgID string
// Instance marks the operator tier, which manages organizations, feature
// toggles and instance-wide configuration. An operator holds NO org scopes
// and reaches no org's workspaces: it is an administration tier, not a
// visibility one.
Instance bool
}
// IsAdmin reports whether the identity carries the admin role.
func (i Identity) IsAdmin() bool { return i.Role == RoleAdmin }
// WithIdentity returns a context carrying the identity.
func WithIdentity(ctx context.Context, id Identity) context.Context {
return context.WithValue(ctx, ctxKey{}, id)
}
// IdentityFromContext reads the identity from a context. It works for both
// gin-request contexts (SetOnGin mirrors onto the request context) and plain
// net/http paths such as the SPA boot-payload builder.
func IdentityFromContext(ctx context.Context) (Identity, bool) {
id, ok := ctx.Value(ctxKey{}).(Identity)
return id, ok
}
// SetOnGin stores the identity on the gin context AND the underlying request
// context, so both gin handlers (FromGin) and plain net/http consumers (the
// NoRoute SPA handler only receives *http.Request) can read it.
func SetOnGin(c *gin.Context, id Identity) {
c.Set(GinContextKey, id)
c.Request = c.Request.WithContext(WithIdentity(c.Request.Context(), id))
}
// FromGin reads the identity from a gin context.
func FromGin(c *gin.Context) (Identity, bool) {
if v, ok := c.Get(GinContextKey); ok {
if id, ok := v.(Identity); ok {
return id, true
}
}
return IdentityFromContext(c.Request.Context())
}
// RequireRealIdentity aborts unless the request carries a non-synthetic
// authenticated identity. While authentication is disabled the middleware
// injects a synthetic single-user admin (see httpmw.SyntheticIdentity) that
// carries RoleAdmin — so RequireAdmin alone would let anyone hitting a
// not-yet-enabled instance mint a PAT or plant an admin that survives
// enablement and hijacks first-run setup. Guarding credential- and
// user-management routes with this closes that hole; public bootstrap routes
// (setup/login/me/invite-accept) are intentionally NOT guarded by it. It
// responds 404 so the management surface simply does not exist while disabled.
func RequireRealIdentity() gin.HandlerFunc {
return func(c *gin.Context) {
id, ok := FromGin(c)
if !ok || id.Synthetic {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.Next()
}
}
// RequireAdmin aborts with 403 unless the request identity is an admin.
// Requests without any identity are rejected with 401.
func RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
id, ok := FromGin(c)
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
if !id.IsAdmin() {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin role required"})
return
}
c.Next()
}
}