mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-10 17:46:15 +00:00
199 lines
7 KiB
Go
199 lines
7 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/actionlifecycle"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
|
)
|
|
|
|
type trustedActionActorContextKey struct{}
|
|
|
|
func withTrustedActionActor(ctx context.Context, actor unified.ActionActor) context.Context {
|
|
return context.WithValue(ctx, trustedActionActorContextKey{}, unified.NormalizeActionActor(actor))
|
|
}
|
|
|
|
func trustedActionActorFromContext(ctx context.Context) (unified.ActionActor, bool) {
|
|
actor, ok := ctx.Value(trustedActionActorContextKey{}).(unified.ActionActor)
|
|
if !ok {
|
|
return unified.ActionActor{}, false
|
|
}
|
|
actor = unified.NormalizeActionActor(actor)
|
|
return actor, unified.ValidateActionActor(actor) == nil
|
|
}
|
|
|
|
func actionCredentialID(prefix, value string) string {
|
|
sum := sha256.Sum256([]byte(value))
|
|
return fmt.Sprintf("%s:%x", prefix, sum[:16])
|
|
}
|
|
|
|
func actionActorForRequest(cfg *config.Config, r *http.Request, orgID string) (unified.ActionActor, error) {
|
|
orgID = strings.TrimSpace(orgID)
|
|
if actor, ok := trustedActionActorFromContext(r.Context()); ok {
|
|
if actor.OrgID != orgID {
|
|
return unified.ActionActor{}, actionlifecycle.ErrActionAuthorizationDenied
|
|
}
|
|
return actor, nil
|
|
}
|
|
if record := getAPITokenRecordFromRequest(r); record != nil {
|
|
subject := apiTokenAuthenticatedUser(record)
|
|
actor := unified.ActionActor{
|
|
SubjectID: subject,
|
|
Kind: unified.ActionActorAPIToken,
|
|
CredentialID: "api-token:" + strings.TrimSpace(record.ID),
|
|
OrgID: orgID,
|
|
}
|
|
if err := unified.ValidateActionActor(actor); err != nil {
|
|
return unified.ActionActor{}, err
|
|
}
|
|
return actor, nil
|
|
}
|
|
user := strings.TrimSpace(auth.GetUser(r.Context()))
|
|
if user == "" && cfg != nil {
|
|
user = strings.TrimSpace(getAuthUsername(cfg, r))
|
|
}
|
|
if user == "" {
|
|
return unified.ActionActor{}, actionlifecycle.ErrActionAuthorizationDenied
|
|
}
|
|
credentialID := ""
|
|
if cookie, err := readSessionCookie(r); err == nil && cookie.Value != "" && ValidateSession(cookie.Value) {
|
|
credentialID = actionCredentialID("session", cookie.Value)
|
|
} else if cfg != nil && cfg.ProxyAuthSecret != "" {
|
|
if valid, proxyUser, _ := CheckProxyAuth(cfg, r); valid && strings.EqualFold(strings.TrimSpace(proxyUser), user) {
|
|
credentialID = actionCredentialID("proxy", user)
|
|
}
|
|
} else if authorization := strings.TrimSpace(r.Header.Get("Authorization")); authorization != "" {
|
|
credentialID = actionCredentialID("http-auth", authorization)
|
|
} else if adminBypassEnabled() {
|
|
credentialID = "development-bypass"
|
|
} else if strings.TrimSpace(auth.GetUser(r.Context())) != "" {
|
|
// Auth context is populated only by trusted server middleware. This
|
|
// covers integrations whose credential was verified upstream without
|
|
// persisting or re-reading that credential here.
|
|
credentialID = actionCredentialID("server-context", user)
|
|
}
|
|
if credentialID == "" {
|
|
return unified.ActionActor{}, actionlifecycle.ErrActionAuthorizationDenied
|
|
}
|
|
return unified.ActionActor{SubjectID: user, Kind: unified.ActionActorUser, CredentialID: credentialID, OrgID: orgID}, nil
|
|
}
|
|
|
|
func approvalEvidenceForRequest(actor unified.ActionActor, record unified.ActionAuditRecord, outcome unified.ApprovalOutcome, now time.Time) unified.ApprovalEvidence {
|
|
method := unified.MethodSession
|
|
if actor.Kind == unified.ActionActorAPIToken {
|
|
method = unified.MethodAPIToken
|
|
}
|
|
return unified.ApprovalEvidence{
|
|
Version: 1,
|
|
Method: method,
|
|
Actor: actor,
|
|
OrgID: actor.OrgID,
|
|
ActionID: record.ID,
|
|
PlanHash: record.Plan.PlanHash,
|
|
Outcome: outcome,
|
|
IssuedAt: now.UTC(),
|
|
}
|
|
}
|
|
|
|
type actionAuthority struct {
|
|
authorizer auth.Authorizer
|
|
orgChecker *DefaultAuthorizationChecker
|
|
}
|
|
|
|
func (a actionAuthority) authorizeActor(ctx context.Context, orgID string, actor unified.ActionActor, capability string) error {
|
|
actor = unified.NormalizeActionActor(actor)
|
|
var token *config.APITokenRecord
|
|
if actor.Kind == unified.ActionActorAPIToken {
|
|
current, ok := auth.GetAPIToken(ctx).(*config.APITokenRecord)
|
|
if !ok || current == nil || actor.CredentialID != "api-token:"+strings.TrimSpace(current.ID) {
|
|
return actionlifecycle.ErrActionAuthorizationDenied
|
|
}
|
|
owner := apiTokenBoundUser(current)
|
|
if owner == "" || !strings.EqualFold(owner, actor.SubjectID) || strings.HasPrefix(actor.SubjectID, "token:") {
|
|
return actionlifecycle.ErrApprovalActorNotHuman
|
|
}
|
|
if !tokenHasActionCapabilityScope(current, capability) {
|
|
return actionlifecycle.ErrActionAuthorizationDenied
|
|
}
|
|
token = current
|
|
} else if actor.Kind != unified.ActionActorUser || !strings.EqualFold(strings.TrimSpace(auth.GetUser(ctx)), actor.SubjectID) {
|
|
return actionlifecycle.ErrApprovalActorNotHuman
|
|
}
|
|
if a.orgChecker == nil || !a.orgChecker.CheckAccess(token, actor.SubjectID, orgID).Allowed {
|
|
return actionlifecycle.ErrActionAuthorizationDenied
|
|
}
|
|
return authorizeActionCapability(ctx, a.authorizer, capability)
|
|
}
|
|
|
|
func tokenHasActionCapabilityScope(token *config.APITokenRecord, capability string) bool {
|
|
if token == nil {
|
|
return false
|
|
}
|
|
var scopes []string
|
|
switch capability {
|
|
case auth.ActionPlan:
|
|
scopes = []string{config.ScopeActionsPlan, config.ScopeAIExecute}
|
|
case auth.ActionApprove:
|
|
scopes = []string{config.ScopeActionsApprove, config.ScopeAIExecute, config.ScopeRelayMobileAccess}
|
|
case auth.ActionExecute:
|
|
scopes = []string{config.ScopeActionsExecute, config.ScopeAIExecute, config.ScopeRelayMobileAccess}
|
|
default:
|
|
return false
|
|
}
|
|
for _, scope := range scopes {
|
|
if token.HasScope(scope) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (a actionAuthority) AuthorizeDecision(ctx context.Context, orgID string, _ unified.ActionAuditRecord, decision unified.ActionDecision) error {
|
|
return a.authorizeActor(ctx, orgID, decision.Actor, auth.ActionApprove)
|
|
}
|
|
|
|
func (a actionAuthority) AuthorizeExecution(ctx context.Context, orgID string, _ unified.ActionAuditRecord, actor unified.ActionActor) error {
|
|
return a.authorizeActor(ctx, orgID, actor, auth.ActionExecute)
|
|
}
|
|
|
|
func authorizeActionCapability(ctx context.Context, authorizer auth.Authorizer, capability string) error {
|
|
if authorizer == nil {
|
|
return actionlifecycle.ErrActionAuthorizationDenied
|
|
}
|
|
allowed, err := authorizer.Authorize(ctx, capability, auth.ResourceActions)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if allowed {
|
|
return nil
|
|
}
|
|
legacyAction := auth.ActionAdmin
|
|
if capability == auth.ActionPlan {
|
|
legacyAction = auth.ActionWrite
|
|
}
|
|
allowed, err = authorizer.Authorize(ctx, legacyAction, auth.ResourceAI)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !allowed {
|
|
return actionlifecycle.ErrActionAuthorizationDenied
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func requireActionCapability(authorizer auth.Authorizer, capability string, handler http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if err := authorizeActionCapability(r.Context(), authorizer, capability); err != nil {
|
|
writeJSONError(w, http.StatusForbidden, "action_capability_denied", "You do not have permission to perform this action")
|
|
return
|
|
}
|
|
handler(w, r)
|
|
}
|
|
}
|