ultisuite-backend/internal/api/middleware/auth.go
R3D347HR4Y d3c930cac6
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Integration tests (push) Waiting to run
CI / DB migrations (push) Waiting to run
feat(identity-providers): add management for identity providers in admin API
- Introduced new endpoints for managing identity providers, including retrieval of redirect URIs and testing/syncing providers.
- Enhanced organization settings to include identity provider configurations, allowing for self-enrollment and domain restrictions.
- Implemented caching for access policies and added validation for identity provider secrets.
- Added integration tests to ensure proper functionality of identity provider management and policy enforcement.
2026-06-09 09:36:38 +02:00

200 lines
6.9 KiB
Go

package middleware
import (
"context"
"log/slog"
"net/http"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/apitokens"
"github.com/ultisuite/ulti-backend/internal/auth"
"github.com/ultisuite/ulti-backend/internal/orgpolicy"
"github.com/ultisuite/ulti-backend/internal/permission"
"github.com/ultisuite/ulti-backend/internal/securityaudit"
"github.com/ultisuite/ulti-backend/internal/users"
)
type ctxKey string
const (
claimsKey ctxKey = "claims"
apiTokenKey ctxKey = "api_token"
)
func Auth(verifier *auth.Holder, db *pgxpool.Pool, audit *securityaudit.Logger, orgPolicy *orgpolicy.Loader) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("Authorization")
if header == "" {
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthMissingAuthorization, "missing authorization header", nil)
if audit != nil {
audit.Log(r.Context(), "anonymous", securityaudit.ActionTokenRejected, map[string]any{
"reason": "missing_authorization_header",
"path": r.URL.Path,
"method": r.Method,
})
}
return
}
token, found := strings.CutPrefix(header, "Bearer ")
if !found {
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthInvalidAuthorization, "invalid authorization header", nil)
if audit != nil {
audit.Log(r.Context(), "anonymous", securityaudit.ActionTokenRejected, map[string]any{
"reason": "invalid_authorization_header",
"path": r.URL.Path,
"method": r.Method,
})
}
return
}
token = strings.TrimSpace(token)
if strings.HasPrefix(token, apitokens.TokenPrefix()) {
if db == nil {
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, apiresponse.CodeAuthUnavailable, "authentication unavailable", nil)
return
}
apiAuth, err := apitokens.Authenticate(r.Context(), db, token)
if err != nil {
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthInvalidToken, "invalid api token", nil)
if audit != nil {
audit.Log(r.Context(), "anonymous", securityaudit.ActionTokenRejected, map[string]any{
"reason": "api_token_verification_failed",
"path": r.URL.Path,
"method": r.Method,
})
}
return
}
if isApiTokenManagementRoute(r.URL.Path) && !apitokens.HasPermission(apiAuth, "automation.api_tokens", true) {
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "api token management requires super admin permission", nil)
return
}
claims := &auth.Claims{
Sub: apiAuth.ExternalID,
Email: apiAuth.Email,
Name: apiAuth.Name,
}
if audit != nil {
audit.Log(r.Context(), claims.Sub, securityaudit.ActionLogin, map[string]any{
"email": claims.Email,
"path": r.URL.Path,
"method": r.Method,
"api_token": apiAuth.TokenID,
"auth_mode": "api_token",
})
}
ctx := context.WithValue(r.Context(), claimsKey, claims)
ctx = context.WithValue(ctx, apiTokenKey, apiAuth)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
if verifier == nil || !verifier.Ready() {
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, apiresponse.CodeAuthUnavailable, "authentication unavailable", nil)
if audit != nil {
audit.Log(r.Context(), "system", securityaudit.ActionTokenRejected, map[string]any{
"reason": "verifier_unavailable",
"path": r.URL.Path,
"method": r.Method,
})
}
return
}
claims, err := verifier.Verify(r.Context(), token)
if err != nil {
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthInvalidToken, "invalid token", nil)
if audit != nil {
audit.Log(r.Context(), "anonymous", securityaudit.ActionTokenRejected, map[string]any{
"reason": "token_verification_failed",
"path": r.URL.Path,
"method": r.Method,
})
}
return
}
if db != nil {
if _, err := users.EnsureUser(r.Context(), db, claims); err != nil {
slog.Error("provision user", "sub", claims.Sub, "error", err)
apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "failed to provision user", nil)
return
}
if err := users.ApplyAccountGroups(r.Context(), db, claims); err != nil {
slog.Error("apply account groups", "sub", claims.Sub, "error", err)
apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "failed to read user privileges", nil)
return
}
var disabled bool
if err := db.QueryRow(r.Context(), `
SELECT status = 'disabled' FROM users WHERE external_id = $1
`, claims.Sub).Scan(&disabled); err != nil {
slog.Error("read user status", "sub", claims.Sub, "error", err)
apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "failed to read user status", nil)
return
}
if disabled {
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "account disabled", nil)
if audit != nil {
audit.Log(r.Context(), claims.Sub, securityaudit.ActionTokenRejected, map[string]any{
"reason": "account_disabled",
"path": r.URL.Path,
"method": r.Method,
})
}
return
}
if orgPolicy != nil {
policy, err := orgPolicy.AuthAccessPolicy(r.Context())
if err != nil {
slog.Error("load auth access policy", "sub", claims.Sub, "error", err)
} else if !policy.AllowsIdentity(claims.Email, claims) {
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeIdentityNotAllowed, "identity not allowed by organization policy", nil)
if audit != nil {
audit.Log(r.Context(), claims.Sub, securityaudit.ActionTokenRejected, map[string]any{
"reason": "identity_not_allowed",
"email": claims.Email,
"path": r.URL.Path,
"method": r.Method,
})
}
return
}
}
} else {
claims.Groups = permission.WithSuiteDefaults(claims.Groups)
}
if audit != nil {
audit.Log(r.Context(), claims.Sub, securityaudit.ActionLogin, map[string]any{
"email": claims.Email,
"path": r.URL.Path,
"method": r.Method,
})
}
ctx := context.WithValue(r.Context(), claimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func ClaimsFromContext(ctx context.Context) *auth.Claims {
claims, _ := ctx.Value(claimsKey).(*auth.Claims)
return claims
}
func ApiTokenFromContext(ctx context.Context) *apitokens.AuthContext {
authCtx, _ := ctx.Value(apiTokenKey).(*apitokens.AuthContext)
return authCtx
}
func isApiTokenManagementRoute(path string) bool {
return strings.Contains(path, "/api-tokens")
}