Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 6 additions & 14 deletions agentplane/app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,9 +183,9 @@ current Sandbox identity and harness, while the central-sampler offer uses `cent
with that same fixed executor environment. Dispatcher validates the placement against the selected
Sandbox UID; this app flow does not create optional central attachments or environment-free central
Sessions.
The generated unary gateway runs the app-owned service in process after cookie-or-reviewed-workload-token
admission and cookie CSRF checks. Token admission also supports explicitly authorized callers and
integration tests; it does not make this a backend service API. The app calls the Dispatcher over gRPC.
The generated unary gateway runs the app-owned service in process after operator-cookie-or-OIDC-ID-token
admission and cookie CSRF checks. Operator ID-token admission also supports scripts and integration
tests; it does not make this a backend service API. The app calls the Dispatcher over gRPC.

The returned minimal `Session` contains its ID, placement, timestamps and nested `SessionStatus`; it
does not expose the immutable spec or instructions. Its materialization state is placement-owner
Expand Down Expand Up @@ -321,7 +321,7 @@ as unavailable while it reconnects rather than treating its old data as current.
## Authentication and authorization

Every API route needs a caller; only `/healthz`, `/readyz` and the `/auth/*` endpoints answer without
one. The separate frontend serves public static files, including `/sw.js`. There are three credentials,
one. The separate frontend serves public static files, including `/sw.js`. There are two credentials,
and all are cryptographic:

- **An operator's OIDC session.** The mounted `app-config` YAML's `oidc` section supplies the issuer,
Expand All @@ -340,20 +340,12 @@ and all are cryptographic:
cookie, and no same-origin check applies, since browsers never send it on their own. The keys come
from the issuer's discovery document; `oidc.static_ed25519_keys` pins keys instead, which the
settings allow only for an issuer under the reserved `.test` domain. The k3s system tests use that.
- **A Kubernetes token.** `Authorization: Bearer <token>` goes to TokenReview, which returns the
username the API server vouches for. The token has to carry the app's audience
(`token_audience` in the mounted YAML, `agentplane` by default), so a token minted for anything
else cannot be replayed here, and the username has to appear in the YAML `token_subjects` list,
or the app answers 403. The audience
would not be a gate on its own: RBAC decides which ServiceAccount a principal may mint a token
for, never which audience it asks for, so an unnamed subject is refused however it minted. Empty
-- the default -- accepts no token caller at all. Live system tests use an operator ID token from
the configured issuer rather than minting this app-specific ServiceAccount token.

```sh
curl -H "Authorization: Bearer $AGENTPLANE_SYSTEM_TEST_TOKEN" https://agentplane-staging.allegedly.works/sandboxes
```

Kubernetes ServiceAccount tokens are rejected; the app does not call TokenReview.

Nothing is inferred from a request header, and nothing in front of the app authenticates for it:
the gateway routes straight to the Service. A header set by an authenticating proxy, such as an
Authentik forward-auth outpost's `x-authentik-username`, cannot be trusted: the API server's service
Expand Down
10 changes: 5 additions & 5 deletions agentplane/app/integration_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,12 +148,13 @@ async def _bearer(api_server: KubernetesApiServer, account: ServiceAccountRef) -


@pytest.fixture
def agent(namespace: SandboxNamespace) -> str:
return f"system:serviceaccount:{namespace.name}:{_AGENT}"
async def operator_auth(app: AppProcess) -> dict[str, str]:
assert app.operator_id_token is not None
return {"Authorization": f"Bearer {app.operator_id_token}"}


@pytest.fixture
async def agent_auth(api_server: KubernetesApiServer, namespace: SandboxNamespace) -> dict[str, str]:
async def kubernetes_auth(api_server: KubernetesApiServer, namespace: SandboxNamespace) -> dict[str, str]:
return await _bearer(api_server, namespace.account(_AGENT))


Expand All @@ -169,15 +170,14 @@ async def app(
sandbox: SandboxCluster,
gateway_service: GatewayProcess,
app_token_file: Path,
agent: str,
app_config: dict[str, object],
) -> AsyncIterator[AppProcess]:
async with app_process(
db_url,
history_client,
sandboxes=SandboxesAccess(kubeconfig=sandbox.server.kubeconfig, namespace=sandbox.namespace),
gateway=GatewayAccess(gateway_service.target, app_token_file, gateway_service.client_tls),
config={"token_audience": AUDIENCE, "token_subjects": [agent]} | app_config,
config=app_config,
) as process:
yield process

Expand Down
5 changes: 0 additions & 5 deletions agentplane/app/internal/auth/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,8 @@ go_library(
],
importpath = "github.com/agentydragon/ducktape/agentplane/app/internal/auth",
deps = [
"//agentplane/identity/auth",
"@com_github_coreos_go_oidc_v3//oidc",
"@com_github_gorilla_securecookie//:securecookie",
"@io_k8s_api//authentication/v1:authentication",
"@io_k8s_apimachinery//pkg/apis/meta/v1:meta",
"@org_golang_x_oauth2//:oauth2",
],
)
Expand All @@ -31,8 +28,6 @@ go_test(
deps = [
"@com_github_go_jose_go_jose_v4//:go-jose",
"@com_github_go_jose_go_jose_v4//jwt",
"@io_k8s_api//authentication/v1:authentication",
"@io_k8s_apimachinery//pkg/apis/meta/v1:meta",
],
)

Expand Down
75 changes: 10 additions & 65 deletions agentplane/app/internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,41 +11,27 @@ import (
"log/slog"
"maps"
"net/http"
"slices"
"strings"
"sync"
"time"

workloadauth "github.com/agentydragon/ducktape/agentplane/identity/auth"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
authenticationv1 "k8s.io/api/authentication/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

type Auth struct {
cfg Config
oidc *openID
oidcMu sync.Mutex
pinned *oidc.IDTokenVerifier
store *sessionStore
signer *cookieSigner
changes ChangeSource
subjects map[string]struct{}
cfg Config
oidc *openID
oidcMu sync.Mutex
pinned *oidc.IDTokenVerifier
store *sessionStore
signer *cookieSigner
changes ChangeSource
}

// New constructs the auth boundary. OIDC discovery is deferred until a browser uses the login route.
func New(cfg Config) (*Auth, error) {
a := &Auth{cfg: cfg, changes: cfg.SessionChanges, subjects: make(map[string]struct{}, len(cfg.TokenSubjects))}
for _, subject := range cfg.TokenSubjects {
if subject == "" {
return nil, errors.New("token subject allowlist entries must not be empty")
}
a.subjects[subject] = struct{}{}
}
if cfg.TokenAudience != "" && cfg.TokenReviews == nil {
return nil, errors.New("TokenReview client is required when token audience is set")
}
a := &Auth{cfg: cfg, changes: cfg.SessionChanges}
if cfg.OIDC == nil {
return a, nil
}
Expand Down Expand Up @@ -168,8 +154,7 @@ func traceAuth(r *http.Request, phase string) {
slog.InfoContext(r.Context(), "auth login phase", "trace_id", id, "path", r.URL.Path, "phase", phase)
}

// RequireCaller protects a route with an operator cookie, an operator's OIDC ID token, or a reviewed
// Kubernetes token.
// RequireCaller protects a route with an operator cookie or an operator's OIDC ID token.
func (a *Auth) RequireCaller(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
caller, status, message := a.caller(r)
Expand Down Expand Up @@ -215,7 +200,6 @@ type CallerKind string

const (
CallerOperator CallerKind = "operator"
CallerToken CallerKind = "token"
)

type Caller struct {
Expand Down Expand Up @@ -557,10 +541,7 @@ func (a *Auth) caller(r *http.Request) (*Caller, int, string) {
if a.cfg.OIDC != nil && unverifiedIssuer(fields[1]) == a.cfg.OIDC.Issuer {
return a.idTokenCaller(r.Context(), fields[1])
}
if a.cfg.TokenReviews == nil || a.cfg.TokenAudience == "" {
return nil, http.StatusUnauthorized, "no session and no accepted token"
}
return a.tokenCaller(r, fields[1])
return nil, http.StatusUnauthorized, "no session and no accepted token"
}

// idTokenCaller accepts an operator's ID token as a cookie session's operator, with no session row.
Expand Down Expand Up @@ -606,42 +587,6 @@ func unverifiedIssuer(token string) string {
return claims.Issuer
}

func (a *Auth) tokenCaller(r *http.Request, token string) (*Caller, int, string) {
return reviewToken(r.Context(), a.cfg.TokenReviews, a.cfg.TokenAudience, a.subjects, token)
}

func reviewToken(ctx context.Context, reviews workloadauth.TokenReviews, audience string, subjects map[string]struct{}, bearer string) (*Caller, int, string) {
review, err := reviews.Create(ctx, &authenticationv1.TokenReview{
Spec: authenticationv1.TokenReviewSpec{Token: bearer, Audiences: []string{audience}},
}, metav1.CreateOptions{})
if err != nil {
return nil, http.StatusServiceUnavailable, "token review unavailable"
}
reason, username := "", ""
switch {
case review == nil || !review.Status.Authenticated:
reason = "unauthenticated"
case review.Status.User.Username == "":
reason = "missing_username"
default:
username = review.Status.User.Username
if !slices.Contains(review.Status.Audiences, audience) {
reason = "wrong_audience"
} else if _, ok := subjects[username]; !ok {
reason = "subject_not_allowed"
}
}
if reason != "" {
// Status.Error and the review spec can contain credentials. Log only curated identity.
slog.InfoContext(ctx, "TokenReview rejected caller", "reason", reason, "username", username)
if reason == "subject_not_allowed" {
return nil, http.StatusForbidden, "token subject is not accepted here"
}
return nil, http.StatusUnauthorized, "no session and no accepted token"
}
return &Caller{Kind: CallerToken, Principal: username}, 0, ""
}

// loginAttempt is the app's server-side state for one authorization-code flow.
type loginAttempt struct {
Nonce string `json:"nonce"`
Expand Down
109 changes: 23 additions & 86 deletions agentplane/app/internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"crypto/rsa"
"database/sql"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
Expand All @@ -19,8 +18,6 @@ import (

"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
authenticationv1 "k8s.io/api/authentication/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

func TestSecureCookieRoundTrip(t *testing.T) {
Expand Down Expand Up @@ -154,52 +151,19 @@ func TestCallbackRejectsInvalidLoginAttempt(t *testing.T) {
}
}

func TestTokenReviewRequiresAudienceAndAllowlistedSubject(t *testing.T) {
reviewer := &fakeTokenReviews{review: &authenticationv1.TokenReview{Status: authenticationv1.TokenReviewStatus{
Authenticated: true,
Audiences: []string{"agentplane"},
User: authenticationv1.UserInfo{Username: "system:serviceaccount:tools:runner"},
}}}
caller, code, _ := reviewToken(context.Background(), reviewer, "agentplane", map[string]struct{}{"system:serviceaccount:tools:runner": {}}, "opaque")
if code != 0 || caller == nil || caller.Kind != CallerToken {
t.Fatalf("accepted TokenReview = (%+v, %d)", caller, code)
}
if len(reviewer.last.Spec.Audiences) != 1 || reviewer.last.Spec.Audiences[0] != "agentplane" || reviewer.last.Spec.Token != "opaque" {
t.Fatalf("TokenReview request = %+v", reviewer.last.Spec)
}

reviewer.review.Status.Audiences = []string{"elsewhere"}
if caller, code, _ := reviewToken(context.Background(), reviewer, "agentplane", map[string]struct{}{"system:serviceaccount:tools:runner": {}}, "opaque"); caller != nil || code != http.StatusUnauthorized {
t.Fatalf("wrong-audience review = (%+v, %d)", caller, code)
}
reviewer.review.Status.Audiences = []string{"agentplane"}
if caller, code, _ := reviewToken(context.Background(), reviewer, "agentplane", map[string]struct{}{}, "opaque"); caller != nil || code != http.StatusForbidden {
t.Fatalf("disallowed subject = (%+v, %d)", caller, code)
}
reviewer.err = errors.New("unavailable")
if caller, code, _ := reviewToken(context.Background(), reviewer, "agentplane", map[string]struct{}{}, "opaque"); caller != nil || code != http.StatusServiceUnavailable {
t.Fatalf("review failure = (%+v, %d)", caller, code)
}
}

func TestRequireCallerContextAndSameOrigin(t *testing.T) {
reviewer := &fakeTokenReviews{review: &authenticationv1.TokenReview{Status: authenticationv1.TokenReviewStatus{
Authenticated: true,
Audiences: []string{"agentplane"},
User: authenticationv1.UserInfo{Username: "system:serviceaccount:tools:runner"},
}}}
a := &Auth{cfg: Config{TokenReviews: reviewer, TokenAudience: "agentplane"}, subjects: map[string]struct{}{"system:serviceaccount:tools:runner": {}}}
a, key := pinnedAuth(t)
called := false
next := a.RequireCaller(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
caller, ok := CallerFromContext(r.Context())
if !ok || caller.Principal != "system:serviceaccount:tools:runner" {
if !ok || caller.Principal != "test-operator" {
t.Errorf("caller from context = (%+v, %v)", caller, ok)
}
w.WriteHeader(http.StatusNoContent)
}))
r := httptest.NewRequest(http.MethodPost, "/protected", nil)
r.Header.Set("Authorization", "Bearer opaque")
r.Header.Set("Authorization", "Bearer "+signedIDToken(t, key, operatorClaims()))
w := httptest.NewRecorder()
next.ServeHTTP(w, r)
if !called || w.Code != http.StatusNoContent {
Expand Down Expand Up @@ -272,55 +236,28 @@ func TestAudienceMustBeTheSingleConfiguredClient(t *testing.T) {
}
}

type fakeTokenReviews struct {
review *authenticationv1.TokenReview
err error
last *authenticationv1.TokenReview
}

func (f *fakeTokenReviews) Create(_ context.Context, review *authenticationv1.TokenReview, _ metav1.CreateOptions) (*authenticationv1.TokenReview, error) {
f.last = review
return f.review, f.err
}

var _ = errors.New

func TestTokenReviewRejectionLogsOnlyCuratedIdentity(t *testing.T) {
const token = "opaque-bearer-must-not-be-logged"
const subject = "system:serviceaccount:testing:caller"
for _, reason := range []string{"unauthenticated", "missing_username", "wrong_audience", "subject_not_allowed"} {
t.Run(reason, func(t *testing.T) {
review := &authenticationv1.TokenReview{Status: authenticationv1.TokenReviewStatus{
Authenticated: true,
User: authenticationv1.UserInfo{Username: subject}, Audiences: []string{"app-audience"}, Error: "private-provider-detail: " + token,
}}
switch reason {
case "unauthenticated":
review.Status.Authenticated = false
case "missing_username":
review.Status.User.Username = ""
case "wrong_audience":
review.Status.Audiences = []string{"wrong"}
}
var log bytes.Buffer
original := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&log, nil)))
defer slog.SetDefault(original)
caller, code, detail := reviewToken(t.Context(), &fakeTokenReviews{review: review}, "app-audience", map[string]struct{}{}, token)
if caller != nil || (code != http.StatusUnauthorized && code != http.StatusForbidden) {
t.Fatalf("rejection = %v %d %s", caller, code, detail)
}
var record map[string]any
if err := json.Unmarshal(log.Bytes(), &record); err != nil {
t.Fatal(err)
}
if record["reason"] != reason {
t.Fatalf("log = %s", log.String())
func TestKubernetesAndOpaqueBearersAreRejected(t *testing.T) {
a, key := pinnedAuth(t)
claims := operatorClaims()
claims["iss"] = "https://kubernetes.default.svc"
claims["sub"] = "system:serviceaccount:agentplane-staging:agentplane-agent"
claims["aud"] = "agentplane"
for _, token := range []string{"opaque-bearer-must-not-be-logged", signedIDToken(t, key, claims)} {
var log bytes.Buffer
original := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&log, nil)))
for _, boundary := range []*Auth{a, {}} {
request := httptest.NewRequest(http.MethodGet, "/protected", nil)
request.Header.Set("Authorization", "Bearer "+token)
caller, status, detail := boundary.caller(request)
if caller != nil || status != http.StatusUnauthorized {
t.Errorf("rejection = %v %d %s", caller, status, detail)
}
if strings.Contains(log.String()+detail, token) || strings.Contains(log.String()+detail, "private-provider-detail") {
t.Fatal("credential-bearing TokenReview data escaped")
if strings.Contains(log.String()+detail, token) {
t.Error("rejected credential escaped")
}
})
}
slog.SetDefault(original)
}
}

Expand Down
Loading
Loading