feat(authz): bind decisions to exact actions
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02e47-6aac-7ee1-914d-0584c75d3c81
This commit is contained in:
parent
7323dd1a60
commit
c473f1971d
28 changed files with 644 additions and 12 deletions
|
|
@ -144,6 +144,7 @@ func (a *Adapter) envelope(request api.CheckRequest, authz AuthorizationRequest,
|
|||
MatchedRule: reason,
|
||||
Resource: request.Resource,
|
||||
Subject: request.Subject,
|
||||
Binding: api.NewDecisionBinding(request),
|
||||
Diagnostics: diagnostics,
|
||||
Provenance: api.DecisionProvenance{
|
||||
Evaluator: EvaluatorName,
|
||||
|
|
@ -168,6 +169,7 @@ func (a *Adapter) failureEnvelope(request api.CheckRequest, authz AuthorizationR
|
|||
MatchedRule: reason,
|
||||
Resource: request.Resource,
|
||||
Subject: request.Subject,
|
||||
Binding: api.NewDecisionBinding(request),
|
||||
Diagnostics: failureDiagnostics(authz, kind, err),
|
||||
Provenance: api.DecisionProvenance{
|
||||
Evaluator: EvaluatorName,
|
||||
|
|
|
|||
|
|
@ -86,6 +86,9 @@ func TestAdapterCheckWrapsKeycloakAllow(t *testing.T) {
|
|||
if got.Effect != api.DecisionEffectAllow || got.Reason != "uma_permission_granted" {
|
||||
t.Fatalf("decision = %s/%s", got.Effect, got.Reason)
|
||||
}
|
||||
if got.Binding == nil || got.Binding.Action != "read" || got.Binding.RequestDigest == "" {
|
||||
t.Fatalf("binding = %+v; want action-bound request", got.Binding)
|
||||
}
|
||||
if got.Provenance.Evaluator != keycloak.EvaluatorName || got.MatchedPolicyVersion != "kc-v2" {
|
||||
t.Fatalf("provenance = %+v matched=%s", got.Provenance, got.MatchedPolicyVersion)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -219,6 +219,7 @@ func (a *Adapter) envelope(request api.CheckRequest, tupleRequest TupleCheckRequ
|
|||
MatchedRule: firstNonEmpty(result.MatchedRule, reason),
|
||||
Resource: request.Resource,
|
||||
Subject: request.Subject,
|
||||
Binding: api.NewDecisionBinding(request),
|
||||
Diagnostics: diagnostics,
|
||||
Provenance: api.DecisionProvenance{
|
||||
Evaluator: EvaluatorName + "/" + a.backendName,
|
||||
|
|
@ -244,6 +245,7 @@ func (a *Adapter) failureEnvelope(request api.CheckRequest, tupleRequest TupleCh
|
|||
MatchedRule: reason,
|
||||
Resource: request.Resource,
|
||||
Subject: request.Subject,
|
||||
Binding: api.NewDecisionBinding(request),
|
||||
Diagnostics: failureDiagnostics(a.backendName, tupleRequest, kind, err),
|
||||
Provenance: api.DecisionProvenance{
|
||||
Evaluator: EvaluatorName + "/" + a.backendName,
|
||||
|
|
|
|||
|
|
@ -73,6 +73,9 @@ func TestAdapterCheckPreservesCARINGFromMatchedTuple(t *testing.T) {
|
|||
if got.Effect != api.DecisionEffectAllow || got.Reason != "direct_tuple" {
|
||||
t.Fatalf("decision = %s/%s; want allow/direct_tuple", got.Effect, got.Reason)
|
||||
}
|
||||
if got.Binding == nil || got.Binding.Action != "read" || got.Binding.RequestDigest == "" {
|
||||
t.Fatalf("binding = %+v; want action-bound request", got.Binding)
|
||||
}
|
||||
if got.Provenance.Evaluator != "relationship-pdp/openfga" || got.Provenance.DirectoryETag != "zed:42" {
|
||||
t.Fatalf("provenance = %+v", got.Provenance)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -223,6 +223,7 @@ func (a *Adapter) envelope(request api.CheckRequest, evaluation EvaluationReques
|
|||
MatchedRule: firstNonEmpty(result.MatchedRule, reason),
|
||||
Resource: request.Resource,
|
||||
Subject: request.Subject,
|
||||
Binding: api.NewDecisionBinding(request),
|
||||
Obligations: append([]api.Obligation(nil), result.Obligations...),
|
||||
Diagnostics: diagnostics,
|
||||
Provenance: api.DecisionProvenance{
|
||||
|
|
@ -248,6 +249,7 @@ func (a *Adapter) failureEnvelope(request api.CheckRequest, evaluation Evaluatio
|
|||
MatchedRule: reason,
|
||||
Resource: request.Resource,
|
||||
Subject: request.Subject,
|
||||
Binding: api.NewDecisionBinding(request),
|
||||
Diagnostics: failureDiagnostics(a.backendName, evaluation, kind, err),
|
||||
Provenance: api.DecisionProvenance{
|
||||
Evaluator: EvaluatorName + "/" + a.backendName,
|
||||
|
|
|
|||
|
|
@ -83,6 +83,9 @@ func TestAdapterCheckWrapsRuleResult(t *testing.T) {
|
|||
if got.Effect != api.DecisionEffectRedact || got.Reason != "masked_internal_document" {
|
||||
t.Fatalf("decision = %s/%s", got.Effect, got.Reason)
|
||||
}
|
||||
if got.Binding == nil || got.Binding.Action != "read" || got.Binding.RequestDigest == "" {
|
||||
t.Fatalf("binding = %+v; want action-bound request", got.Binding)
|
||||
}
|
||||
if got.Provenance.Evaluator != "rule-pdp/opa" || got.MatchedPolicyVersion != "v2" {
|
||||
t.Fatalf("provenance = %+v matched=%s", got.Provenance, got.MatchedPolicyVersion)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -190,6 +190,7 @@ func (a *Adapter) envelope(request api.CheckRequest, topazRequest DirectoryCheck
|
|||
MatchedRule: firstNonEmpty(result.MatchedRule, reason),
|
||||
Resource: request.Resource,
|
||||
Subject: request.Subject,
|
||||
Binding: api.NewDecisionBinding(request),
|
||||
Obligations: append([]api.Obligation(nil), result.Obligations...),
|
||||
Diagnostics: diagnostics,
|
||||
Provenance: api.DecisionProvenance{
|
||||
|
|
@ -226,6 +227,7 @@ func (a *Adapter) failureEnvelope(request api.CheckRequest, topazRequest Directo
|
|||
MatchedRule: reason,
|
||||
Resource: request.Resource,
|
||||
Subject: request.Subject,
|
||||
Binding: api.NewDecisionBinding(request),
|
||||
Diagnostics: diagnostics,
|
||||
Provenance: api.DecisionProvenance{
|
||||
Evaluator: EvaluatorName,
|
||||
|
|
|
|||
|
|
@ -77,6 +77,9 @@ func TestAdapterCheckWrapsTopazAllowInFlexAuthEnvelope(t *testing.T) {
|
|||
if got.Effect != api.DecisionEffectAllow || got.Reason != "topaz_directory_allow" {
|
||||
t.Fatalf("decision = %s/%s; want allow/topaz_directory_allow", got.Effect, got.Reason)
|
||||
}
|
||||
if got.Binding == nil || got.Binding.Action != "read" || got.Binding.RequestDigest == "" {
|
||||
t.Fatalf("binding = %+v; want action-bound request", got.Binding)
|
||||
}
|
||||
if got.Provenance.Evaluator != topaz.EvaluatorName || got.Provenance.Mode != topaz.DelegatedMode {
|
||||
t.Fatalf("provenance = %+v; want delegated Topaz", got.Provenance)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,9 @@ func (a *Authenticator) authorize(ctx context.Context, authorization string, sys
|
|||
}
|
||||
identity, err := a.reviewer.Review(ctx, strings.TrimSpace(token))
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUnauthenticated) {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||||
}
|
||||
if strings.TrimSpace(identity.Username) == "" || !contains(identity.Audiences, a.audience) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package callerauth
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
|
@ -53,6 +54,11 @@ func TestAuthenticatorRejectsMissingTokenAndReviewerFailure(t *testing.T) {
|
|||
if err := unavailable.Authorize(context.Background(), "Bearer token", []string{"tenant-engine"}); !errors.Is(err, ErrUnavailable) {
|
||||
t.Fatalf("reviewer error = %v; want unavailable", err)
|
||||
}
|
||||
|
||||
rejected, _ := New(ModeEnforce, fakeReviewer{err: fmt.Errorf("%w: invalid bearer token", ErrUnauthenticated)}, "flex-auth", bindings, nil)
|
||||
if err := rejected.Authorize(context.Background(), "Bearer malformed", []string{"tenant-engine"}); !errors.Is(err, ErrUnauthenticated) {
|
||||
t.Fatalf("rejected token error = %v; want unauthenticated", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticatorWarnModePermitsButRecordsFailure(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ func (r *KubernetesTokenReviewer) Review(ctx context.Context, callerToken string
|
|||
return Identity{}, fmt.Errorf("decode TokenReview: %w", err)
|
||||
}
|
||||
if review.Status.Error != "" {
|
||||
return Identity{}, fmt.Errorf("TokenReview: %s", review.Status.Error)
|
||||
return Identity{}, fmt.Errorf("%w: TokenReview: %s", ErrUnauthenticated, review.Status.Error)
|
||||
}
|
||||
if !review.Status.Authenticated {
|
||||
return Identity{}, nil
|
||||
|
|
|
|||
46
internal/callerauth/tokenreview_test.go
Normal file
46
internal/callerauth/tokenreview_test.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package callerauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestKubernetesTokenReviewerClassifiesRejectedTokenAsUnauthenticated(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/apis/authentication.k8s.io/v1/tokenreviews" {
|
||||
t.Fatalf("TokenReview path = %q", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{
|
||||
"apiVersion": "authentication.k8s.io/v1",
|
||||
"kind": "TokenReview",
|
||||
"status": {
|
||||
"authenticated": false,
|
||||
"error": "invalid bearer token"
|
||||
}
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tokenFile := filepath.Join(t.TempDir(), "reviewer-token")
|
||||
if err := os.WriteFile(tokenFile, []byte("reviewer-token\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reviewer := &KubernetesTokenReviewer{
|
||||
endpoint: server.URL + "/apis/authentication.k8s.io/v1/tokenreviews",
|
||||
audience: "flex-auth",
|
||||
reviewerTokenFile: tokenFile,
|
||||
client: server.Client(),
|
||||
}
|
||||
|
||||
_, err := reviewer.Review(context.Background(), "malformed")
|
||||
if !errors.Is(err, ErrUnauthenticated) {
|
||||
t.Fatalf("Review error = %v; want unauthenticated", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -296,6 +296,7 @@ func (e *Engine) envelope(request api.CheckRequest, expectation api.DecisionExpe
|
|||
MatchedRule: expectation.Reason,
|
||||
Resource: request.Resource,
|
||||
Subject: request.Subject,
|
||||
Binding: api.NewDecisionBinding(request),
|
||||
Obligations: expectation.Obligations,
|
||||
Diagnostics: map[string]any{
|
||||
"action": request.Action,
|
||||
|
|
|
|||
|
|
@ -49,6 +49,15 @@ func TestCheckUsesExplicitCaringContext(t *testing.T) {
|
|||
if got.Resource.Type != "document" || got.Resource.Attributes["trust_zone"] != "internal" {
|
||||
t.Errorf("got.Resource = %+v; want enriched document resource", got.Resource)
|
||||
}
|
||||
if got.Binding == nil {
|
||||
t.Fatal("got.Binding is nil")
|
||||
}
|
||||
if got.Binding.Action != request.Action || got.Binding.Subject.ID != got.Subject.ID || got.Binding.Resource.ID != got.Resource.ID {
|
||||
t.Errorf("got.Binding = %+v; want normalized action, subject, and resource", got.Binding)
|
||||
}
|
||||
if !strings.HasPrefix(got.Binding.RequestDigest, "sha256:") || len(got.Binding.RequestDigest) != len("sha256:")+64 {
|
||||
t.Errorf("got.Binding.RequestDigest = %q; want full SHA-256 digest", got.Binding.RequestDigest)
|
||||
}
|
||||
if got.Caring == nil || got.Caring.Descriptor == nil {
|
||||
t.Fatal("got.Caring.Descriptor is nil")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue