Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/live-agent-credential-admission.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"server": minor
---

Enforce live agent credential authorization against delegated permissions and current agent and owner policies. Reject inactive or expired credentials while preserving authentication errors on private MCP requests.
2 changes: 2 additions & 0 deletions .speakeasy/out.openapi.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion server/cmd/gram/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
"github.com/speakeasy-api/gram/server/internal/access"
"github.com/speakeasy-api/gram/server/internal/agent"
"github.com/speakeasy-api/gram/server/internal/agentmanagement"
"github.com/speakeasy-api/gram/server/internal/agents/runtimepolicy"
"github.com/speakeasy-api/gram/server/internal/aiintegrations"
"github.com/speakeasy-api/gram/server/internal/assets"
"github.com/speakeasy-api/gram/server/internal/assistant_platform_mcp_adapter"
Expand Down Expand Up @@ -954,7 +955,8 @@ func newStartCommand() *cli.Command {
challengeLoggingEnabled,
roleClient,
authz.EngineOpts{
DevMode: c.String("environment") == "local",
AdmitPrincipalCredential: runtimepolicy.AdmitPrincipalCredential,
DevMode: c.String("environment") == "local",
})

telemetryLogPublisher := tm.NewLogPublisher(logger, tracerProvider, meterProvider, publishers.TelemetryLogs)
Expand Down
4 changes: 3 additions & 1 deletion server/cmd/gram/worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"go.temporal.io/sdk/client"
"go.temporal.io/sdk/worker"

"github.com/speakeasy-api/gram/server/internal/agents/runtimepolicy"
"github.com/speakeasy-api/gram/server/internal/assistants"
"github.com/speakeasy-api/gram/server/internal/attr"
"github.com/speakeasy-api/gram/server/internal/auth/assistanttokens"
Expand Down Expand Up @@ -562,7 +563,8 @@ func newWorkerCommand() *cli.Command {
challengeLoggingEnabled,
workos.NewStubClient(),
authz.EngineOpts{
DevMode: c.String("environment") == "local",
AdmitPrincipalCredential: runtimepolicy.AdmitPrincipalCredential,
DevMode: c.String("environment") == "local",
})

workosClient, workosAvailable, err := newWorkOSClient(guardianPolicy, c)
Expand Down
4 changes: 2 additions & 2 deletions server/design/access/design.go
Original file line number Diff line number Diff line change
Expand Up @@ -1081,7 +1081,7 @@ var AuthzChallengeModel = Type("AuthzChallenge", func() {
Attribute("principal_urn", String, "Principal URN e.g. user:<uuid> or api_key:<id>.")
Attribute("principal_type", String, func() {
Description("Kind of principal.")
Enum("user", "api_key", "assistant")
Enum("user", "api_key", "assistant", "agent")
})
Attribute("user_email", String, "Email when available.")
Attribute("photo_url", String, "User avatar URL when available.")
Expand Down Expand Up @@ -1141,7 +1141,7 @@ var ChallengeBucketModel = Type("ChallengeBucket", func() {
Attribute("principal_urn", String, "Principal URN e.g. user:<uuid> or api_key:<id>.")
Attribute("principal_type", String, func() {
Description("Kind of principal.")
Enum("user", "api_key", "assistant")
Enum("user", "api_key", "assistant", "agent")
})
Attribute("user_email", String, "Email when available.")
Attribute("photo_url", String, "User avatar URL when available.")
Expand Down
8 changes: 4 additions & 4 deletions server/gen/http/access/client/types.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions server/gen/http/openapi3.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

118 changes: 118 additions & 0 deletions server/internal/agents/runtimepolicy/credential_admission.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package runtimepolicy

import (
"context"
"errors"
"fmt"

"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/speakeasy-api/gram/server/internal/agents"
"github.com/speakeasy-api/gram/server/internal/authz"
"github.com/speakeasy-api/gram/server/internal/contextvalues"
keysrepo "github.com/speakeasy-api/gram/server/internal/keys/repo"
"github.com/speakeasy-api/gram/server/internal/o11y"
"github.com/speakeasy-api/gram/server/internal/oops"
"github.com/speakeasy-api/gram/server/internal/urn"
)

// AdmitPrincipalCredential performs authoritative parent admission and loads
// immutable credential policy R, live direct agent policy A, and the current
// owner's live policy O as three independent policy sets. The caller must first
// load and validate the directly active credential row, stamp its immutable
// profile with contextvalues.WithPrincipalCredentialAuthorization, and call
// this function before minting credentials, resolving upstream authority, or
// executing an operation. Successful results must not be cached across requests.
func AdmitPrincipalCredential(ctx context.Context, db *pgxpool.Pool) (authz.PrincipalCredentialAdmission, error) {
authCtx, ok := contextvalues.GetAuthContext(ctx)
credential, hasCredential := contextvalues.PrincipalCredentialAuthorization(ctx)
actor, hasActor := contextvalues.AuthenticatedActor(ctx)
if !ok || authCtx == nil || !hasCredential || !hasActor ||
authCtx.ActiveOrganizationID == "" || credential.AuthorizerUserID == "" || actor.Type != urn.PrincipalTypeAgent {
return authz.PrincipalCredentialAdmission{}, oops.C(oops.CodeUnauthorized)
}

policy, err := DecodeDelegatedPolicy(DelegatedPolicyVersion(credential.DelegatedGrantsVersion), credential.DelegatedGrants)
if err != nil {
if errors.Is(err, ErrInvalidDelegatedPolicy) {
return authz.PrincipalCredentialAdmission{}, oops.C(oops.CodeUnauthorized)
}
return authz.PrincipalCredentialAdmission{}, fmt.Errorf("decode delegated credential policy: %w", err)
}

tx, err := db.BeginTx(ctx, pgx.TxOptions{
IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly, DeferrableMode: pgx.NotDeferrable, BeginQuery: "", CommitQuery: "",
})
if err != nil {
return authz.PrincipalCredentialAdmission{}, fmt.Errorf("begin credential admission snapshot: %w", err)
}
defer o11y.NoLogDefer(func() error { return tx.Rollback(ctx) })

if mode, hasMode := contextvalues.APIKeyAuthorization(ctx); hasMode && mode == contextvalues.APIKeyAuthorizationModePrincipal {
apiKeyID, parseErr := uuid.Parse(authCtx.APIKeyID)
if parseErr != nil {
return authz.PrincipalCredentialAdmission{}, oops.C(oops.CodeUnauthorized)
}
_, err = keysrepo.New(tx).GetActivePrincipalAPIKeyForAdmission(ctx, keysrepo.GetActivePrincipalAPIKeyForAdmissionParams{
ID: apiKeyID,
OrganizationID: authCtx.ActiveOrganizationID,
SubjectUrn: pgtype.Text{String: actor.String(), Valid: true},
AuthorizerUserID: credential.AuthorizerUserID,
DelegatedGrants: credential.DelegatedGrants,
DelegatedGrantsVersion: pgtype.Int4{Int32: credential.DelegatedGrantsVersion, Valid: true},
})
if errors.Is(err, pgx.ErrNoRows) {
return authz.PrincipalCredentialAdmission{}, oops.C(oops.CodeUnauthorized)
}
if err != nil {
return authz.PrincipalCredentialAdmission{}, fmt.Errorf("revalidate principal API key: %w", err)
}
}

agent, err := agents.ResolvePrincipal(ctx, tx, authCtx.ActiveOrganizationID, actor)
if err != nil {
if errors.Is(err, agents.ErrPrincipalInvalid) || errors.Is(err, agents.ErrPrincipalNotFound) {
return authz.PrincipalCredentialAdmission{}, oops.C(oops.CodeUnauthorized)
}
return authz.PrincipalCredentialAdmission{}, fmt.Errorf("resolve credential parent: %w", err)
}
if agents.DeriveLifecycle(agent) != agents.LifecycleActive || agent.OwnerReassignmentRequiredAt.Valid {
return authz.PrincipalCredentialAdmission{}, oops.C(oops.CodeUnauthorized)
}

ownerPrincipal := urn.NewPrincipal(urn.PrincipalTypeUser, agent.OwnerUserID)
ownerPrincipals, err := authz.ResolveUserPrincipals(ctx, tx, authCtx.ActiveOrganizationID, agent.OwnerUserID)
if err != nil {
if errors.Is(err, authz.ErrPrincipalInvalid) || errors.Is(err, authz.ErrPrincipalNotFound) {
return authz.PrincipalCredentialAdmission{}, oops.C(oops.CodeUnauthorized)
}
return authz.PrincipalCredentialAdmission{}, fmt.Errorf("resolve credential owner: %w", err)
}
ownerEligible := false
for _, principal := range ownerPrincipals {
if principal.String() == ownerPrincipal.String() {
ownerEligible = true
break
}
}
if !ownerEligible {
return authz.PrincipalCredentialAdmission{}, oops.C(oops.CodeUnauthorized)
}

agentPolicy, err := LoadAgentPolicy(ctx, tx, authCtx.ActiveOrganizationID, actor)
if err != nil {
return authz.PrincipalCredentialAdmission{}, fmt.Errorf("load live agent policy: %w", err)
}
ownerPolicy, err := authz.LoadGrants(ctx, tx, authCtx.ActiveOrganizationID, ownerPrincipals)
if err != nil {
return authz.PrincipalCredentialAdmission{}, fmt.Errorf("load live owner policy: %w", err)
}

if err := tx.Commit(ctx); err != nil {
return authz.PrincipalCredentialAdmission{}, fmt.Errorf("commit credential admission snapshot: %w", err)
}

return authz.PrincipalCredentialAdmission{OwnerUserID: agent.OwnerUserID, Credential: policy.RuntimeGrants(), Agent: agentPolicy, Owner: ownerPolicy}, nil
}
Loading
Loading