diff --git a/.env.example b/.env.example index ee28af4a0..648fa47c6 100644 --- a/.env.example +++ b/.env.example @@ -40,8 +40,9 @@ AUTH_SESSION_TTL=24h # explicit and easy to audit. # --------------------------------------------------------------------------- HTTP_LISTEN=0.0.0.0:7410 -# Optional shared bearer token for daemon HTTP(S) control-plane requests. -# Leave empty to disable daemon authentication. Unix socket access, health, +# Optional environment-managed default-admin token for daemon HTTP(S) requests. +# Once initialized, removing it revokes this token but does not reopen anonymous +# access. Unix socket access, health, # runtime LLM facade, Jupyter proxy, and webhook ingestion use separate trust # boundaries and do not require this token. AGENT_COMPOSE_AUTH_TOKEN= diff --git a/Taskfile.yml b/Taskfile.yml index d65cfb37b..fb60864a6 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -150,6 +150,13 @@ tasks: - ./scripts/tests/test-binary-ci-contract.sh - ./scripts/tests/test-image-ci-contract.sh + test:rbac: + desc: Build a local daemon and run the CLI RBAC and audit workflow + dir: '{{.TASKFILE_DIR}}' + cmds: + - task: build:agent-compose + - ./scripts/test-agent-compose-rbac.sh --binary ./build/agent-compose + test:deploy: desc: Run deterministic installer, Compose, and release-asset deployment checks dir: '{{.TASKFILE_DIR}}' diff --git a/cmd/agent-compose/cli_auth.go b/cmd/agent-compose/cli_auth.go index 871788357..666ce2a23 100644 --- a/cmd/agent-compose/cli_auth.go +++ b/cmd/agent-compose/cli_auth.go @@ -9,6 +9,9 @@ import ( "github.com/spf13/cobra" "agent-compose/pkg/clientconfig" + agentcomposev2 "agent-compose/proto/agentcompose/v2" + "agent-compose/proto/agentcompose/v2/agentcomposev2connect" + "connectrpc.com/connect" ) type cliAuthLoginOptions struct { @@ -51,7 +54,15 @@ func newCLIAuthCommand(options *cliOptions) *cobra.Command { Args: cobra.NoArgs, RunE: runCLIAuthList, } - authCmd.AddCommand(loginCmd, logoutCmd, listCmd) + whoAmICmd := &cobra.Command{ + Use: "whoami", + Short: "Show the current daemon token identity", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runCLIAuthWhoAmI(cmd, *options) + }, + } + authCmd.AddCommand(loginCmd, logoutCmd, listCmd, whoAmICmd, newCLIAuthRoleCommand(options), newCLIAuthTokenCommand(options)) return authCmd } @@ -68,11 +79,16 @@ func runCLIAuthLogin(cmd *cobra.Command, hostFlag string, options cliAuthLoginOp return commandExitError{Code: exitCodeUsage, Err: fmt.Errorf("auth login requires --host or AGENT_COMPOSE_HOST")} } clientConfig.AuthToken = token - if _, err := fetchDaemonVersion(cmd.Context(), clientConfig); err != nil { + client := agentcomposev2connect.NewAuthServiceClient(newDaemonHTTPClient(clientConfig), clientConfig.BaseURL) + identity, err := client.WhoAmI(cmd.Context(), connect.NewRequest(&agentcomposev2.WhoAmIRequest{})) + if err != nil { var statusErr daemonHTTPStatusError if errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusUnauthorized { return fmt.Errorf("authentication failed for %s: token was rejected (HTTP %d)", clientConfig.BaseURL, statusErr.StatusCode) } + if connect.CodeOf(err) == connect.CodeUnauthenticated { + return fmt.Errorf("authentication failed for %s: token was rejected", clientConfig.BaseURL) + } return fmt.Errorf("authenticate daemon %s: %w", clientConfig.BaseURL, err) } path, err := clientconfig.DefaultPath() @@ -82,7 +98,23 @@ func runCLIAuthLogin(cmd *cobra.Command, hostFlag string, options cliAuthLoginOp if err := clientconfig.SaveToken(path, clientConfig.BaseURL, token); err != nil { return err } - _, err = fmt.Fprintf(cmd.OutOrStdout(), "Authenticated %s\n", clientConfig.BaseURL) + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Authenticated %s as %s (%s)\n", clientConfig.BaseURL, identity.Msg.GetToken().GetName(), identity.Msg.GetRole()) + return err +} + +func runCLIAuthWhoAmI(cmd *cobra.Command, options cliOptions) error { + clients, err := newCLIServiceClients(options) + if err != nil { + return err + } + response, err := clients.auth.WhoAmI(cmd.Context(), connect.NewRequest(&agentcomposev2.WhoAmIRequest{})) + if err != nil { + return fmt.Errorf("query daemon identity: %w", err) + } + if options.JSON { + return writeProtoJSON(cmd.OutOrStdout(), response.Msg) + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "TOKEN\tROLE\tORIGIN\n%s\t%s\t%s\n", firstNonEmptyString(response.Msg.GetToken().GetName(), "-"), response.Msg.GetRole(), response.Msg.GetOrigin()) return err } diff --git a/cmd/agent-compose/cli_auth_test.go b/cmd/agent-compose/cli_auth_test.go index c45b9f7cf..db06ff93a 100644 --- a/cmd/agent-compose/cli_auth_test.go +++ b/cmd/agent-compose/cli_auth_test.go @@ -8,6 +8,10 @@ import ( "path/filepath" "strings" "testing" + + agentcomposev2 "agent-compose/proto/agentcompose/v2" + "agent-compose/proto/agentcompose/v2/agentcomposev2connect" + "google.golang.org/protobuf/proto" ) func TestCLIAuthLoginPersistsAndReusesToken(t *testing.T) { @@ -18,6 +22,17 @@ func TestCLIAuthLoginPersistsAndReusesToken(t *testing.T) { w.WriteHeader(http.StatusUnauthorized) return } + if r.URL.Path == agentcomposev2connect.AuthServiceWhoAmIProcedure { + body, err := proto.Marshal(&agentcomposev2.WhoAmIResponse{ + Token: &agentcomposev2.AuthToken{Name: "test-admin"}, Role: "admin", Origin: "issued", AuthenticationInitialized: true, + }) + if err != nil { + t.Fatal(err) + } + w.Header().Set("Content-Type", "application/proto") + _, _ = w.Write(body) + return + } _, _ = io.WriteString(w, `{"err":null,"msg":"OK","data":{"timestamp":1783501631,"version":"test"}}`) })) defer server.Close() @@ -74,7 +89,7 @@ func TestCLIAuthLoginFailureDoesNotPersistToken(t *testing.T) { if exitCode == 0 { t.Fatal("login exit code = 0, want failure") } - wantError := "authentication failed for " + server.URL + ": token was rejected (HTTP 401)\n" + wantError := "authentication failed for " + server.URL + ": token was rejected\n" if stderr != wantError { t.Fatalf("login stderr = %q, want %q", stderr, wantError) } diff --git a/cmd/agent-compose/cli_security.go b/cmd/agent-compose/cli_security.go new file mode 100644 index 000000000..990de1d76 --- /dev/null +++ b/cmd/agent-compose/cli_security.go @@ -0,0 +1,281 @@ +package main + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "text/tabwriter" + "time" + + "connectrpc.com/connect" + "github.com/google/uuid" + "github.com/spf13/cobra" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" + + agentcomposev2 "agent-compose/proto/agentcompose/v2" +) + +type cliTokenCreateOptions struct { + Name string + Description string + Role string +} + +type cliAuditListOptions struct { + Token string + Action string + ResourceType string + ResourceID string + ProjectID string + Status string + Since string + Until string + Limit uint32 + Cursor string +} + +func newCLIAuthRoleCommand(options *cliOptions) *cobra.Command { + roleCmd := &cobra.Command{Use: "role", Short: "Inspect daemon roles", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() }} + roleCmd.AddCommand(&cobra.Command{ + Use: "ls", Short: "List daemon roles", Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { return runCLIAuthRoleList(cmd, *options) }, + }) + return roleCmd +} + +func newCLIAuthTokenCommand(options *cliOptions) *cobra.Command { + tokenCmd := &cobra.Command{Use: "token", Short: "Manage daemon tokens", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() }} + includeRevoked := false + limit := uint32(0) + cursor := "" + listCmd := &cobra.Command{ + Use: "ls", Short: "List daemon tokens", Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runCLIAuthTokenList(cmd, *options, includeRevoked, limit, cursor) + }, + } + listCmd.Flags().BoolVar(&includeRevoked, "all", false, "Include revoked tokens") + listCmd.Flags().Uint32Var(&limit, "limit", 0, "Maximum tokens to return") + listCmd.Flags().StringVar(&cursor, "cursor", "", "Continue from a token cursor") + + createOptions := cliTokenCreateOptions{} + createCmd := &cobra.Command{ + Use: "create", Short: "Create a client-generated daemon token", Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { return runCLIAuthTokenCreate(cmd, *options, createOptions) }, + } + createCmd.Flags().StringVar(&createOptions.Name, "name", "", "Unique token name") + createCmd.Flags().StringVar(&createOptions.Description, "description", "", "Token description") + createCmd.Flags().StringVar(&createOptions.Role, "role", "", "Token role: admin or read-only-admin") + _ = createCmd.MarkFlagRequired("name") + _ = createCmd.MarkFlagRequired("role") + + revokeCmd := &cobra.Command{ + Use: "revoke ", Short: "Revoke a daemon token by id or name", Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { return runCLIAuthTokenRevoke(cmd, *options, args[0]) }, + } + tokenCmd.AddCommand(listCmd, createCmd, revokeCmd) + return tokenCmd +} + +func newCLIAuditCommand(options *cliOptions) *cobra.Command { + auditCmd := &cobra.Command{Use: "audit", Short: "Inspect daemon operation audits", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() }} + listOptions := cliAuditListOptions{} + listCmd := &cobra.Command{ + Use: "ls", Short: "List daemon operation audits", Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { return runCLIAuditList(cmd, *options, listOptions) }, + } + listCmd.Flags().StringVar(&listOptions.Token, "token", "", "Filter by token id or name") + listCmd.Flags().StringVar(&listOptions.Action, "action", "", "Filter by action") + listCmd.Flags().StringVar(&listOptions.ResourceType, "resource-type", "", "Filter by resource type") + listCmd.Flags().StringVar(&listOptions.ResourceID, "resource", "", "Filter by resource id") + listCmd.Flags().StringVar(&listOptions.ProjectID, "project", "", "Filter by project id") + listCmd.Flags().StringVar(&listOptions.Status, "status", "", "Filter by status") + listCmd.Flags().StringVar(&listOptions.Since, "since", "", "Filter audits starting at RFC3339 time") + listCmd.Flags().StringVar(&listOptions.Until, "until", "", "Filter audits ending at RFC3339 time") + listCmd.Flags().Uint32Var(&listOptions.Limit, "limit", 0, "Maximum audits to return") + listCmd.Flags().StringVar(&listOptions.Cursor, "cursor", "", "Continue from an audit cursor") + auditCmd.AddCommand(listCmd) + return auditCmd +} + +func runCLIAuthRoleList(cmd *cobra.Command, options cliOptions) error { + clients, err := newCLIServiceClients(options) + if err != nil { + return err + } + response, err := clients.auth.ListRoles(cmd.Context(), connect.NewRequest(&agentcomposev2.ListRolesRequest{})) + if err != nil { + return fmt.Errorf("list daemon roles: %w", err) + } + if options.JSON { + return writeProtoJSON(cmd.OutOrStdout(), response.Msg) + } + tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(tw, "ROLE\tREAD ONLY\tDESCRIPTION") + for _, item := range response.Msg.GetRoles() { + _, _ = fmt.Fprintf(tw, "%s\t%t\t%s\n", item.GetName(), item.GetReadOnly(), item.GetDescription()) + } + return tw.Flush() +} + +func runCLIAuthTokenList(cmd *cobra.Command, options cliOptions, includeRevoked bool, limit uint32, cursor string) error { + clients, err := newCLIServiceClients(options) + if err != nil { + return err + } + response, err := clients.auth.ListTokens(cmd.Context(), connect.NewRequest(&agentcomposev2.ListTokensRequest{IncludeRevoked: includeRevoked, Limit: limit, Cursor: cursor})) + if err != nil { + return fmt.Errorf("list daemon tokens: %w", err) + } + if options.JSON { + return writeProtoJSON(cmd.OutOrStdout(), response.Msg) + } + tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(tw, "ID\tNAME\tROLE\tORIGIN\tSTATUS\tCREATED") + for _, item := range response.Msg.GetTokens() { + status := "active" + if item.GetRevokedAt() != nil { + status = "revoked" + } + _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", shortDisplayID(item.GetId()), item.GetName(), item.GetRole(), item.GetOrigin(), status, formatProtoTime(item.GetCreatedAt())) + } + return tw.Flush() +} + +func runCLIAuthTokenCreate(cmd *cobra.Command, options cliOptions, createOptions cliTokenCreateOptions) error { + secret, err := generateClientToken() + if err != nil { + return err + } + clients, err := newCLIServiceClients(options) + if err != nil { + return err + } + response, err := clients.auth.CreateToken(cmd.Context(), connect.NewRequest(&agentcomposev2.CreateTokenRequest{ + Name: createOptions.Name, Description: createOptions.Description, Role: createOptions.Role, + Token: secret, ClientRequestId: uuid.NewString(), + })) + if err != nil { + return fmt.Errorf("create daemon token: %w", err) + } + if options.JSON { + output := struct { + Token string `json:"token"` + Item struct { + ID string `json:"id"` + Name string `json:"name"` + Role string `json:"role"` + Origin string `json:"origin"` + } `json:"item"` + Created bool `json:"created"` + }{Token: secret, Created: response.Msg.GetCreated()} + output.Item.ID, output.Item.Name, output.Item.Role, output.Item.Origin = response.Msg.GetItem().GetId(), response.Msg.GetItem().GetName(), response.Msg.GetItem().GetRole(), response.Msg.GetItem().GetOrigin() + data, marshalErr := json.MarshalIndent(output, "", " ") + if marshalErr != nil { + return marshalErr + } + return writeCommandOutput(cmd.OutOrStdout(), append(data, '\n')) + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Token created. This secret is shown once; store it securely.\nName: %s\nRole: %s\nToken: %s\n", response.Msg.GetItem().GetName(), response.Msg.GetItem().GetRole(), secret) + return err +} + +func runCLIAuthTokenRevoke(cmd *cobra.Command, options cliOptions, ref string) error { + clients, err := newCLIServiceClients(options) + if err != nil { + return err + } + response, err := clients.auth.RevokeToken(cmd.Context(), connect.NewRequest(&agentcomposev2.RevokeTokenRequest{Token: ref})) + if err != nil { + return fmt.Errorf("revoke daemon token: %w", err) + } + if options.JSON { + return writeProtoJSON(cmd.OutOrStdout(), response.Msg) + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Revoked %s (%s)\n", response.Msg.GetItem().GetName(), shortDisplayID(response.Msg.GetItem().GetId())) + return err +} + +func runCLIAuditList(cmd *cobra.Command, options cliOptions, listOptions cliAuditListOptions) error { + request := &agentcomposev2.ListOperationAuditsRequest{Token: listOptions.Token, Action: listOptions.Action, ResourceType: listOptions.ResourceType, ResourceId: listOptions.ResourceID, ProjectId: listOptions.ProjectID, Status: listOptions.Status, Limit: listOptions.Limit, Cursor: listOptions.Cursor} + var err error + if listOptions.Since != "" { + request.StartedAfter, err = parseCLITimestamp(listOptions.Since) + if err != nil { + return err + } + } + if listOptions.Until != "" { + request.StartedBefore, err = parseCLITimestamp(listOptions.Until) + if err != nil { + return err + } + } + clients, err := newCLIServiceClients(options) + if err != nil { + return err + } + response, err := clients.auth.ListOperationAudits(cmd.Context(), connect.NewRequest(request)) + if err != nil { + return fmt.Errorf("list operation audits: %w", err) + } + if options.JSON { + return writeProtoJSON(cmd.OutOrStdout(), response.Msg) + } + return writeAuditTable(cmd.OutOrStdout(), response.Msg.GetAudits()) +} + +func writeAuditTable(out io.Writer, items []*agentcomposev2.OperationAudit) error { + tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(tw, "TIME\tTOKEN\tACTION\tOBJECT\tSTATUS") + for _, item := range items { + object := item.GetResourceType() + if item.GetResourceId() != "" { + object += "/" + item.GetResourceId() + } + _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", formatProtoTime(item.GetStartedAt()), firstNonEmptyString(item.GetTokenName(), item.GetOrigin()), item.GetAction(), object, item.GetStatus()) + } + return tw.Flush() +} + +func generateClientToken() (string, error) { + data := make([]byte, 32) + if _, err := rand.Read(data); err != nil { + return "", fmt.Errorf("generate daemon token: %w", err) + } + return "ac_" + base64.RawURLEncoding.EncodeToString(data), nil +} + +func writeProtoJSON(out io.Writer, message proto.Message) error { + data, err := protojson.MarshalOptions{Indent: " "}.Marshal(message) + if err != nil { + return err + } + return writeCommandOutput(out, append(data, '\n')) +} + +func parseCLITimestamp(value string) (*timestamppb.Timestamp, error) { + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return nil, commandExitError{Code: exitCodeUsage, Err: fmt.Errorf("invalid RFC3339 time %q: %w", value, err)} + } + return timestamppb.New(parsed), nil +} + +func formatProtoTime(value *timestamppb.Timestamp) string { + if value == nil { + return "-" + } + return value.AsTime().Local().Format("2006-01-02 15:04:05") +} + +func shortDisplayID(value string) string { + if len(value) > 12 { + return value[:12] + } + return value +} diff --git a/cmd/agent-compose/daemon_auth.go b/cmd/agent-compose/daemon_auth.go index 5b74410d0..861cb2133 100644 --- a/cmd/agent-compose/daemon_auth.go +++ b/cmd/agent-compose/daemon_auth.go @@ -1,39 +1,73 @@ package main import ( - "crypto/sha256" - "crypto/subtle" + "context" "net/http" "strings" "github.com/labstack/echo/v4" agentcomposeapp "agent-compose/pkg/agentcompose/app" + controlauth "agent-compose/pkg/auth" "agent-compose/pkg/config" "agent-compose/proto/health/v1/healthv1connect" ) const bearerScheme = "Bearer " -func newDaemonAuthMiddleware(conf *config.Config) echo.MiddlewareFunc { - tokenHash := sha256.Sum256([]byte(conf.DaemonAuthToken)) +type daemonAuthenticator interface { + Initialized() bool + Authenticate(context.Context, string) (controlauth.Identity, error) + RegisterRequest(string, context.CancelFunc) func() +} + +func newDaemonAuthMiddleware(conf *config.Config, authenticator daemonAuthenticator) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { - if conf.DaemonAuthToken == "" || trustedLocalSocketRequest(c.Request()) || daemonAuthExemptRequest(c.Request(), conf.JupyterProxyBasePath) { + request := c.Request() + if daemonAuthExemptRequest(request, conf.JupyterProxyBasePath) { + return next(c) + } + if trustedLocalSocketRequest(request) { + c.SetRequest(request.WithContext(controlauth.WithIdentity(request.Context(), controlauth.Identity{ + TokenName: "local-admin", Role: controlauth.RoleAdmin, Origin: controlauth.OriginLocal, + }))) + return next(c) + } + + values := request.Header.Values(echo.HeaderAuthorization) + if len(values) == 0 && !authenticator.Initialized() { + c.SetRequest(request.WithContext(controlauth.WithIdentity(request.Context(), controlauth.Identity{ + TokenName: "anonymous-admin", Role: controlauth.RoleAdmin, Origin: controlauth.OriginAnonymous, + }))) return next(c) } - presented, ok := requestBearerToken(c.Request()) - presentedHash := sha256.Sum256([]byte(presented)) - if !ok || subtle.ConstantTimeCompare(tokenHash[:], presentedHash[:]) != 1 { - c.Response().Header().Set("WWW-Authenticate", `Bearer realm="agent-compose"`) - c.Response().Header().Set("Cache-Control", "no-store") - return echo.NewHTTPError(http.StatusUnauthorized, "daemon authentication required") + presented, ok := requestBearerToken(request) + if !ok { + return daemonUnauthorized(c) + } + identity, err := authenticator.Authenticate(request.Context(), presented) + if err != nil { + return daemonUnauthorized(c) } + requestCtx, cancel := context.WithCancel(controlauth.WithIdentity(request.Context(), identity)) + unregister := authenticator.RegisterRequest(identity.TokenID, cancel) + defer func() { + unregister() + cancel() + }() + c.SetRequest(request.WithContext(requestCtx)) return next(c) } } } +func daemonUnauthorized(c echo.Context) error { + c.Response().Header().Set("WWW-Authenticate", `Bearer realm="agent-compose"`) + c.Response().Header().Set("Cache-Control", "no-store") + return echo.NewHTTPError(http.StatusUnauthorized, "daemon authentication required") +} + func trustedLocalSocketRequest(r *http.Request) bool { trusted, _ := r.Context().Value(localUnixSocketRequestKey{}).(bool) return trusted diff --git a/cmd/agent-compose/daemon_auth_test.go b/cmd/agent-compose/daemon_auth_test.go index fbca8ac80..15ba5e892 100644 --- a/cmd/agent-compose/daemon_auth_test.go +++ b/cmd/agent-compose/daemon_auth_test.go @@ -2,15 +2,30 @@ package main import ( "context" + "errors" "net/http" "net/http/httptest" "testing" "github.com/labstack/echo/v4" + controlauth "agent-compose/pkg/auth" "agent-compose/pkg/config" ) +type daemonAuthenticatorFake struct{ token string } + +func (f daemonAuthenticatorFake) Initialized() bool { return f.token != "" } + +func (f daemonAuthenticatorFake) Authenticate(_ context.Context, token string) (controlauth.Identity, error) { + if token != f.token { + return controlauth.Identity{}, errors.New("invalid token") + } + return controlauth.Identity{TokenID: "token-1", TokenName: "test", Role: controlauth.RoleAdmin, Origin: controlauth.OriginIssued}, nil +} + +func (daemonAuthenticatorFake) RegisterRequest(string, context.CancelFunc) func() { return func() {} } + func TestDaemonAuthMiddleware(t *testing.T) { tests := []struct { name string @@ -35,7 +50,7 @@ func TestDaemonAuthMiddleware(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { app := echo.New() - app.Use(newDaemonAuthMiddleware(&config.Config{DaemonAuthToken: test.token, JupyterProxyBasePath: "/jupyter"})) + app.Use(newDaemonAuthMiddleware(&config.Config{DaemonAuthToken: test.token, JupyterProxyBasePath: "/jupyter"}, daemonAuthenticatorFake{token: test.token})) app.Any("/*", func(c echo.Context) error { return c.NoContent(http.StatusNoContent) }) req := httptest.NewRequest(test.method, test.path, nil) if test.header != "" { diff --git a/cmd/agent-compose/daemon_security.go b/cmd/agent-compose/daemon_security.go new file mode 100644 index 000000000..a566fff77 --- /dev/null +++ b/cmd/agent-compose/daemon_security.go @@ -0,0 +1,110 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + controlauth "agent-compose/pkg/auth" + "github.com/google/uuid" + "github.com/labstack/echo/v4" +) + +func newDaemonRequestIDMiddleware() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + requestID := strings.TrimSpace(c.Request().Header.Get("X-Request-ID")) + if requestID == "" || len(requestID) > 128 || strings.ContainsAny(requestID, "\r\n") { + requestID = uuid.NewString() + c.Request().Header.Set("X-Request-ID", requestID) + } + c.Response().Header().Set("X-Request-ID", requestID) + return next(c) + } + } +} + +func newDaemonHTTPSecurityMiddleware(service *controlauth.Service, jupyterBasePath string) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + request := c.Request() + policy, applies := rawHTTPPolicy(request, jupyterBasePath) + if !applies || policy.access == controlauth.AccessRead { + return next(c) + } + actor, ok := controlauth.IdentityFromContext(request.Context()) + if !ok { + return daemonUnauthorized(c) + } + if !controlauth.Allowed(actor.Role, policy.access) { + _ = service.DenyAudit(context.WithoutCancel(request.Context()), actor, request.Header.Get("X-Request-ID"), policy.action) + return echo.NewHTTPError(http.StatusForbidden, controlauth.ErrPermissionDenied.Error()) + } + + resource := rawHTTPResource(c, policy.resourceType) + params, _ := json.Marshal(map[string]string{"method": request.Method, "path": request.URL.Path}) + audit, err := service.BeginAudit(request.Context(), actor, request.Header.Get("X-Request-ID"), policy.action, resource, string(params)) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "begin operation audit") + } + callErr := next(c) + status := controlauth.AuditStatusSucceeded + errorCode, errorMessage := "", "" + if callErr != nil { + status = controlauth.AuditStatusFailed + errorCode = fmt.Sprintf("http_%d", httpStatusForError(callErr)) + errorMessage = callErr.Error() + } + if finishErr := service.FinishAudit(context.WithoutCancel(request.Context()), audit, status, resource, "{}", errorCode, errorMessage); finishErr != nil { + if callErr != nil { + return fmt.Errorf("%w; finish operation audit: %v", callErr, finishErr) + } + return echo.NewHTTPError(http.StatusInternalServerError, "finish operation audit") + } + return callErr + } + } +} + +type rawHTTPRoutePolicy struct { + access controlauth.Access + action string + resourceType string +} + +func rawHTTPPolicy(request *http.Request, jupyterBasePath string) (rawHTTPRoutePolicy, bool) { + if request == nil || request.URL == nil || daemonAuthExemptRequest(request, jupyterBasePath) || strings.HasPrefix(request.URL.Path, "/agentcompose.v2.") { + return rawHTTPRoutePolicy{}, false + } + if request.Method == http.MethodGet { + return rawHTTPRoutePolicy{access: controlauth.AccessRead}, true + } + if request.Method == http.MethodPost && strings.HasPrefix(request.URL.Path, "/api/agent-compose/workspaces/") && strings.HasSuffix(request.URL.Path, "/upload") { + return rawHTTPRoutePolicy{access: controlauth.AccessOperation, action: "workspace.upload", resourceType: "workspace"}, true + } + if (request.Method == http.MethodPut || request.Method == http.MethodDelete) && strings.HasPrefix(request.URL.Path, "/api/webhook-sources/") { + action := "webhook-source.update" + if request.Method == http.MethodDelete { + action = "webhook-source.delete" + } + return rawHTTPRoutePolicy{access: controlauth.AccessOperation, action: action, resourceType: "webhook-source"}, true + } + return rawHTTPRoutePolicy{}, false +} + +func rawHTTPResource(c echo.Context, resourceType string) controlauth.Resource { + id := strings.TrimSpace(c.Param("workspaceID")) + if id == "" { + id = strings.TrimSpace(c.Param("source_id")) + } + return controlauth.Resource{Type: resourceType, ID: id} +} + +func httpStatusForError(err error) int { + if httpErr, ok := err.(*echo.HTTPError); ok { + return httpErr.Code + } + return http.StatusInternalServerError +} diff --git a/cmd/agent-compose/daemon_security_test.go b/cmd/agent-compose/daemon_security_test.go new file mode 100644 index 000000000..0df3c9b6b --- /dev/null +++ b/cmd/agent-compose/daemon_security_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + controlauth "agent-compose/pkg/auth" + "github.com/labstack/echo/v4" +) + +func TestRawHTTPPolicyCoversManagementRoutes(t *testing.T) { + tests := []struct { + method string + path string + access controlauth.Access + action string + apply bool + }{ + {http.MethodGet, "/api/webhook-sources", controlauth.AccessRead, "", true}, + {http.MethodGet, "/api/agent-compose/workspaces/ws/files", controlauth.AccessRead, "", true}, + {http.MethodPost, "/api/agent-compose/workspaces/ws/upload", controlauth.AccessOperation, "workspace.upload", true}, + {http.MethodPut, "/api/webhook-sources/github", controlauth.AccessOperation, "webhook-source.update", true}, + {http.MethodDelete, "/api/webhook-sources/github", controlauth.AccessOperation, "webhook-source.delete", true}, + {http.MethodPost, "/api/webhooks/webhook.github.push", "", "", false}, + {http.MethodPost, "/agentcompose.v2.AuthService/CreateToken", "", "", false}, + {http.MethodGet, "/jupyter/sandbox-1", "", "", false}, + } + for _, test := range tests { + request := httptest.NewRequest(test.method, test.path, nil) + policy, applies := rawHTTPPolicy(request, "/jupyter") + if applies != test.apply || policy.access != test.access || policy.action != test.action { + t.Errorf("%s %s policy = %#v/%v", test.method, test.path, policy, applies) + } + } +} + +func TestRequestIDMiddlewarePreservesValidAndReplacesInvalidIDs(t *testing.T) { + for _, test := range []struct{ provided string }{{"request-1"}, {"bad\nrequest"}, {""}} { + request := httptest.NewRequest(http.MethodGet, "/api/version", nil) + request.Header.Set("X-Request-ID", test.provided) + response := httptest.NewRecorder() + handler := newDaemonRequestIDMiddleware()(func(c echo.Context) error { return c.NoContent(http.StatusNoContent) }) + app := echo.New() + app.GET("/api/version", handler) + app.ServeHTTP(response, request) + got := response.Header().Get("X-Request-ID") + if got == "" || got == "bad\nrequest" || test.provided == "request-1" && got != test.provided { + t.Errorf("provided %q produced %q", test.provided, got) + } + } +} diff --git a/cmd/agent-compose/main.go b/cmd/agent-compose/main.go index 69c6e4015..aa2cce64a 100644 --- a/cmd/agent-compose/main.go +++ b/cmd/agent-compose/main.go @@ -49,6 +49,7 @@ import ( "agent-compose/pkg/agentcompose/api" agentcomposeapp "agent-compose/pkg/agentcompose/app" + controlauth "agent-compose/pkg/auth" "agent-compose/pkg/compose" "agent-compose/pkg/config" driverpkg "agent-compose/pkg/driver" @@ -178,7 +179,7 @@ func NewDaemonApp(ctx context.Context, opts DaemonOptions) (*DaemonApp, error) { app := do.MustInvoke[*echo.Echo](di) logger := do.MustInvoke[*slog.Logger](di) conf := do.MustInvoke[*config.Config](di) - installDaemonMiddleware(app, conf) + installDaemonMiddleware(app, conf, do.MustInvoke[*controlauth.Service](di)) startBackground := opts.StartBackground if startBackground == nil { @@ -193,10 +194,14 @@ func NewDaemonApp(ctx context.Context, opts DaemonOptions) (*DaemonApp, error) { }, nil } -func installDaemonMiddleware(app *echo.Echo, conf *config.Config) { +func installDaemonMiddleware(app *echo.Echo, conf *config.Config, authenticator daemonAuthenticator) { app.Use(middleware.RequestLogger()) app.Use(middleware.Recover()) - app.Use(newDaemonAuthMiddleware(conf)) + app.Use(newDaemonRequestIDMiddleware()) + app.Use(newDaemonAuthMiddleware(conf, authenticator)) + if service, ok := authenticator.(*controlauth.Service); ok { + app.Use(newDaemonHTTPSecurityMiddleware(service, conf.JupyterProxyBasePath)) + } } func (a *DaemonApp) StartBackground() error { @@ -1008,7 +1013,8 @@ func newRootCommand(out, errOut io.Writer, runDaemon daemonRunner) *cobra.Comman } authCmd := newCLIAuthCommand(&options) - root.AddCommand(daemonCmd, versionCmd, statusCmd, authCmd, configCmd, projectCmd, agentCmd, listCmd, upCmd, downCmd, runCmd, schedulerCmd, logsCmd, psCmd, statsCmd, sandboxCmd, stopCmd, resumeCmd, rmCmd, execCmd, imagesCmd, cacheCmd, volumeCmd, imageCmd, pullCmd, buildCmd, rmiCmd, inspectCmd) + auditCmd := newCLIAuditCommand(&options) + root.AddCommand(daemonCmd, versionCmd, statusCmd, authCmd, auditCmd, configCmd, projectCmd, agentCmd, listCmd, upCmd, downCmd, runCmd, schedulerCmd, logsCmd, psCmd, statsCmd, sandboxCmd, stopCmd, resumeCmd, rmCmd, execCmd, imagesCmd, cacheCmd, volumeCmd, imageCmd, pullCmd, buildCmd, rmiCmd, inspectCmd) return root } @@ -5610,6 +5616,7 @@ type cliServiceClients struct { cache agentcomposev2connect.CacheServiceClient volume agentcomposev2connect.VolumeServiceClient sandbox agentcomposev2connect.SandboxServiceClient + auth agentcomposev2connect.AuthServiceClient } type composePSOutput struct { @@ -6455,6 +6462,7 @@ func newCLIServiceClients(cli cliOptions) (cliServiceClients, error) { cache: agentcomposev2connect.NewCacheServiceClient(httpClient, clientConfig.BaseURL), volume: agentcomposev2connect.NewVolumeServiceClient(httpClient, clientConfig.BaseURL), sandbox: agentcomposev2connect.NewSandboxServiceClient(httpClient, clientConfig.BaseURL), + auth: agentcomposev2connect.NewAuthServiceClient(httpClient, clientConfig.BaseURL), }, nil } diff --git a/docs/design/daemon_bearer_auth.md b/docs/design/daemon_bearer_auth.md index 8ccf4d320..58589915b 100644 --- a/docs/design/daemon_bearer_auth.md +++ b/docs/design/daemon_bearer_auth.md @@ -1,93 +1,82 @@ -# Daemon Bearer Authentication +# Daemon Bearer Authentication and RBAC ## Scope -agent-compose optionally authenticates HTTP and HTTPS control-plane requests -with one daemon-wide shared Bearer token. The feature is intended to protect a -daemon endpoint from unauthenticated control-plane operations without adding -users, sessions, JWTs, refresh tokens, or role-based authorization. +agent-compose authenticates remote HTTP(S) control-plane requests with named +Bearer tokens. Tokens carry one built-in role: `admin` has full control-plane +access, while `read-only-admin` can query every control-plane entity, status, +event, and run log but cannot start, stop, create, update, upload, revoke, or +delete resources. Trusted Unix socket requests use the local admin identity. -The token grants full control-plane access. It is not an identity or a -fine-grained permission boundary. +`AGENT_COMPOSE_AUTH_TOKEN` bootstraps the environment-managed `default-admin` +token. Once any token row has existed, authentication stays initialized even if +the environment variable is later removed; this prevents an accidental restart +from reopening the daemon anonymously. Changing the variable rotates the +environment token, and removing it revokes that token. Issued tokens remain +valid independently. -## Daemon configuration +## Token lifecycle -`AGENT_COMPOSE_AUTH_TOKEN` controls the feature: +Token secrets are generated by the client, sent only in `CreateTokenRequest`, +and displayed once from the client's memory after creation succeeds. The API +never returns a plaintext secret or hash. The daemon stores a SHA-256 hash plus +a short fingerprint and metadata. `client_request_id` makes a retry with the +same actor and identical request idempotent; changing any input for that ID is a +conflict. -- empty or unset: authentication is disabled; -- non-empty: protected HTTP(S) requests require - `Authorization: Bearer `; -- trusted Unix socket requests remain credential-free. - -There is deliberately no separate enable flag or token-file option. This avoids -contradictory states such as authentication being enabled without a token. The -daemon loads the token once during startup and never writes or logs it. - -Presented and configured tokens are hashed with SHA-256 before constant-time -comparison. Authentication failures return HTTP 401 with -`WWW-Authenticate: Bearer realm="agent-compose"` and `Cache-Control: no-store`. +```bash +agent-compose --host https://compose.example.com auth token create \ + --name ci-reader --role read-only-admin +agent-compose --host https://compose.example.com auth token ls +agent-compose --host https://compose.example.com auth token revoke ci-reader +``` -## Route policy +An admin cannot revoke itself, an environment-managed token through the API, or +the last active admin remotely. Trusted local Unix socket administration can +recover issued-token configurations. Revocation cancels active requests owned +by the revoked token. -Authentication is default-on for network routes when the daemon token is set. -Only boundaries that already have another credential or operational trust -model are explicitly exempt. +## Route policy and audit -| Boundary | Daemon token | Reason | -| --- | --- | --- | -| Connect control-plane services | Required | Full daemon operations | -| `/api/version` and other daemon APIs | Required | Used to verify CLI login and prevent unauthenticated discovery | -| Workspace file APIs | Required | Read/write project data | -| Health Connect service | Exempt | Orchestrator readiness and liveness | -| Runtime LLM facade | Exempt | Uses a per-sandbox facade token | -| Jupyter proxy | Exempt | Uses the Jupyter/UI access boundary | -| `POST /api/webhooks/:topic` | Exempt | Uses each webhook source token | -| Webhook source management and event APIs | Required | Administrative control-plane operations | +Every v2 Connect procedure has an explicit read or operation policy; an +unclassified procedure fails closed. Read-only access includes project, +scheduler, run, sandbox, image, cache, volume, settings, event, token metadata, +audit records, and streaming run logs. Mutations require `admin`. -Unknown future routes are protected by default. An exemption must be narrow, -documented, and backed by another authentication or trust boundary. +The same rule covers raw HTTP management routes: workspace list/download and +event/webhook-source GET routes are reads; workspace upload and webhook-source +PUT/DELETE routes are operations. Health, runtime LLM facade, Jupyter, and +webhook ingestion retain their independent trust boundaries. -## CLI login and credential storage +Successful and failed operations, plus denied operation attempts, are recorded +in SQLite. Pure reads are not audited. Audit summaries use an allowlist and +omit tokens, passwords, environment values, headers, and build arguments; +large prompt-like content is represented only by length and SHA-256 digest. -The CLI verifies and saves credentials with: +## CLI credential storage ```bash agent-compose --host https://compose.example.com auth login --token '' +agent-compose --host https://compose.example.com auth whoami +agent-compose --host https://compose.example.com auth role ls +agent-compose --host https://compose.example.com audit ls ``` -Login sends the supplied token to the protected `/api/version` endpoint. The -credential is persisted only after a successful response. `auth logout` -removes one site and `auth ls` prints site names without tokens. - -The default store is the platform user configuration directory at -`agent-compose/config.yml`. On standard Linux systems this is -`~/.config/agent-compose/config.yml`. `AGENT_COMPOSE_CONFIG` can override the -path for controlled environments and tests. +Login verifies the identity through `WhoAmI` before saving the credential. The +default config is the platform user configuration file +`agent-compose/config.yml` (normally `~/.config/agent-compose/config.yml`): ```yaml version: 1 hosts: https://compose.example.com: - token: secret + token: ac_example ``` -Writes use an atomic same-directory rename and mode `0600`. A same-directory -advisory lock covers each complete read-modify-write transaction so concurrent -CLI login and logout processes cannot overwrite one another's changes. Tokens -are keyed by the normalized daemon base URL. HTTP(S) client construction -automatically loads the matching token and injects it into ordinary, streaming, -and attach requests. Unix socket client construction does not read or send saved -tokens. - -## Transport and proxy considerations - -Bearer authentication does not encrypt traffic. Plain HTTP remains supported -for loopback container mappings and controlled private deployments, but an -observer can capture and replay the token. Cross-machine deployments should -use HTTPS, an SSH tunnel, a VPN, or equivalent transport protection. - -The middleware authenticates the daemon control plane, not a specific client -program. A UI server or reverse proxy that calls protected daemon APIs must -inject the same Authorization header before daemon authentication is enabled. -Authorization headers and configuration contents must be redacted from logs, -diagnostics, and support bundles. +Writes use an atomic rename, advisory lock, and mode `0600`. Tokens are keyed by +normalized daemon base URL and automatically attached to ordinary, streaming, +and attach requests. `AGENT_COMPOSE_CONFIG` overrides the path for controlled +environments and tests. + +Bearer authentication does not encrypt traffic. Cross-machine deployments +should use HTTPS, an SSH tunnel, a VPN, or equivalent transport protection. diff --git a/docs/pages/command-line-manual.md b/docs/pages/command-line-manual.md index 900a170ab..48d396549 100644 --- a/docs/pages/command-line-manual.md +++ b/docs/pages/command-line-manual.md @@ -41,10 +41,11 @@ Rules: ### Daemon authentication -Set `AGENT_COMPOSE_AUTH_TOKEN` in the daemon environment to require a shared -Bearer token for HTTP(S) control-plane requests. Leaving it empty keeps -authentication disabled. Trusted local Unix socket connections do not use this -authentication path. +Set `AGENT_COMPOSE_AUTH_TOKEN` in the daemon environment to bootstrap the +environment-managed `default-admin` Bearer token. Trusted local Unix socket +connections use a local admin identity. After authentication has been +initialized, removing the environment variable revokes that bootstrap token +but does not reopen the daemon anonymously. Verify and save a token for a daemon site: @@ -53,13 +54,32 @@ agent-compose --host https://compose.example.com auth login --token '' agent-compose --host https://compose.example.com status ``` -The first command verifies the token against the daemon before saving it under +The first command verifies the token with `WhoAmI` before saving it under `~/.config/agent-compose/config.yml` (or the platform user configuration directory). Later commands automatically load the token associated with the normalized `--host` or `AGENT_COMPOSE_HOST` value. The file is written with owner-only permissions. Use `agent-compose auth ls` to list saved sites and `agent-compose --host auth logout` to remove one. +The daemon provides two built-in roles. `admin` can perform all operations; +`read-only-admin` can query all entity metadata, events, audit records, and run +logs, but cannot create, start, stop, update, upload, revoke, or delete. Manage +identities and audit records with: + +```bash +agent-compose --host auth whoami +agent-compose --host auth role ls +agent-compose --host auth token ls +agent-compose --host auth token create --name ci-reader --role read-only-admin +agent-compose --host auth token revoke ci-reader +agent-compose --host audit ls +``` + +`auth token create` generates the secret in the CLI, sends it to the daemon, +and prints the CLI's in-memory value once after success. Token APIs never return +the plaintext secret or its hash; the daemon stores only a hash and metadata. +Save the one-time output immediately. + HTTP remains supported, including loopback container port mappings, but a Bearer token sent over plain HTTP can be observed and replayed. Use HTTPS, an SSH tunnel, a VPN, or another protected network when the CLI and daemon are on diff --git a/docs/pages/zh-CN/command-line-manual.md b/docs/pages/zh-CN/command-line-manual.md index 9bcc8a4c7..94726f8b9 100644 --- a/docs/pages/zh-CN/command-line-manual.md +++ b/docs/pages/zh-CN/command-line-manual.md @@ -42,9 +42,10 @@ agent-compose --host http://10.0.0.12:7410 ls --json ### Daemon 认证 -在 daemon 环境中设置 `AGENT_COMPOSE_AUTH_TOKEN` 后,HTTP(S) 控制面请求必须 -携带共享 Bearer Token;配置为空时认证默认关闭。受信任的本地 Unix Socket -连接不走此认证路径。 +在 daemon 环境中设置 `AGENT_COMPOSE_AUTH_TOKEN`,可初始化由环境管理的 +`default-admin` Bearer Token。受信任的本地 Unix Socket 使用本地管理员身份。 +认证一旦初始化,后续删除环境变量只会撤销该启动 Token,不会让 daemon 重新 +退回匿名开放状态。 为 daemon 站点验证并保存 Token: @@ -53,12 +54,29 @@ agent-compose --host https://compose.example.com auth login --token '' agent-compose --host https://compose.example.com status ``` -第一条命令会先向 daemon 验证 Token,成功后写入 +第一条命令会先通过 `WhoAmI` 向 daemon 验证 Token,成功后写入 `~/.config/agent-compose/config.yml`(或当前平台的用户配置目录)。后续命令会 根据规范化后的 `--host` 或 `AGENT_COMPOSE_HOST` 自动加载对应 Token。配置文件 仅允许当前用户读写。可通过 `agent-compose auth ls` 查看已保存的站点,通过 `agent-compose --host auth logout` 删除站点凭据。 +daemon 内置两个角色:`admin` 可执行全部操作;`read-only-admin` 可查询所有实体 +元数据、事件、审计记录和运行日志,但不能创建、启动、停止、更新、上传、撤销 +或删除资源。相关命令如下: + +```bash +agent-compose --host auth whoami +agent-compose --host auth role ls +agent-compose --host auth token ls +agent-compose --host auth token create --name ci-reader --role read-only-admin +agent-compose --host auth token revoke ci-reader +agent-compose --host audit ls +``` + +`auth token create` 在 CLI 本地生成 Secret,发送给 daemon,并在创建成功后从 +CLI 内存中仅回显一次。Token API 绝不返回明文 Secret 或其哈希;daemon 只保存 +哈希和元数据。请立即安全保存这次输出。 + 当前仍支持 HTTP,包括宿主机访问容器回环映射的场景;但明文 HTTP 中的 Bearer Token 可能被监听并重放。CLI 与 daemon 位于不同机器时,应使用 HTTPS、SSH 隧道、VPN 或其他受保护网络。 diff --git a/pkg/agentcompose/api/auth_handler.go b/pkg/agentcompose/api/auth_handler.go new file mode 100644 index 000000000..9ebd5917d --- /dev/null +++ b/pkg/agentcompose/api/auth_handler.go @@ -0,0 +1,168 @@ +package api + +import ( + "context" + "errors" + "strconv" + "strings" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + controlauth "agent-compose/pkg/auth" + agentcomposev2 "agent-compose/proto/agentcompose/v2" +) + +type AuthHandler struct{ service *controlauth.Service } + +func NewAuthHandler(service *controlauth.Service) *AuthHandler { return &AuthHandler{service: service} } + +func (h *AuthHandler) WhoAmI(ctx context.Context, _ *connect.Request[agentcomposev2.WhoAmIRequest]) (*connect.Response[agentcomposev2.WhoAmIResponse], error) { + identity, ok := controlauth.IdentityFromContext(ctx) + if !ok { + return nil, connect.NewError(connect.CodeUnauthenticated, controlauth.ErrInvalidToken) + } + return connect.NewResponse(&agentcomposev2.WhoAmIResponse{ + Token: authTokenToProto(identity.TokenSummary()), + Role: string(identity.Role), + Origin: identity.Origin, + AuthenticationInitialized: h.service.Initialized(), + }), nil +} + +func (h *AuthHandler) ListRoles(context.Context, *connect.Request[agentcomposev2.ListRolesRequest]) (*connect.Response[agentcomposev2.ListRolesResponse], error) { + response := &agentcomposev2.ListRolesResponse{} + for _, role := range controlauth.Roles() { + response.Roles = append(response.Roles, &agentcomposev2.AuthRole{ + Name: string(role.Name), Description: role.Description, ReadOnly: role.ReadOnly, Builtin: role.Builtin, + }) + } + return connect.NewResponse(response), nil +} + +func (h *AuthHandler) ListTokens(ctx context.Context, req *connect.Request[agentcomposev2.ListTokensRequest]) (*connect.Response[agentcomposev2.ListTokensResponse], error) { + result, err := h.service.ListTokens(ctx, controlauth.TokenListOptions{ + IncludeRevoked: req.Msg.GetIncludeRevoked(), Limit: int(req.Msg.GetLimit()), BeforeID: strings.TrimSpace(req.Msg.GetCursor()), + }) + if err != nil { + return nil, authConnectError(err) + } + response := &agentcomposev2.ListTokensResponse{NextCursor: result.NextCursor} + for _, item := range result.Tokens { + response.Tokens = append(response.Tokens, authTokenToProto(item)) + } + return connect.NewResponse(response), nil +} + +func (h *AuthHandler) CreateToken(ctx context.Context, req *connect.Request[agentcomposev2.CreateTokenRequest]) (*connect.Response[agentcomposev2.CreateTokenResponse], error) { + identity, ok := controlauth.IdentityFromContext(ctx) + if !ok { + return nil, connect.NewError(connect.CodeUnauthenticated, controlauth.ErrInvalidToken) + } + item, created, replay, err := h.service.CreateToken(ctx, identity, controlauth.CreateTokenInput{ + Name: req.Msg.GetName(), Description: req.Msg.GetDescription(), Role: controlauth.Role(req.Msg.GetRole()), + Plaintext: req.Msg.GetToken(), ClientRequestID: req.Msg.GetClientRequestId(), + }, req.Header().Get("X-Request-ID")) + if err != nil { + return nil, authConnectError(err) + } + return connect.NewResponse(&agentcomposev2.CreateTokenResponse{Item: authTokenToProto(item), Created: created, IdempotentReplay: replay}), nil +} + +func (h *AuthHandler) RevokeToken(ctx context.Context, req *connect.Request[agentcomposev2.RevokeTokenRequest]) (*connect.Response[agentcomposev2.RevokeTokenResponse], error) { + identity, ok := controlauth.IdentityFromContext(ctx) + if !ok { + return nil, connect.NewError(connect.CodeUnauthenticated, controlauth.ErrInvalidToken) + } + item, revoked, alreadyRevoked, err := h.service.RevokeToken(ctx, identity, req.Msg.GetToken(), req.Header().Get("X-Request-ID")) + if err != nil { + return nil, authConnectError(err) + } + return connect.NewResponse(&agentcomposev2.RevokeTokenResponse{Item: authTokenToProto(item), Revoked: revoked, AlreadyRevoked: alreadyRevoked}), nil +} + +func (h *AuthHandler) ListOperationAudits(ctx context.Context, req *connect.Request[agentcomposev2.ListOperationAuditsRequest]) (*connect.Response[agentcomposev2.ListOperationAuditsResponse], error) { + beforeSequence, err := parseAuditCursor(req.Msg.GetCursor()) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + filter := controlauth.AuditFilter{ + Token: req.Msg.GetToken(), Action: req.Msg.GetAction(), ResourceType: req.Msg.GetResourceType(), + ResourceID: req.Msg.GetResourceId(), ProjectID: req.Msg.GetProjectId(), Status: controlauth.AuditStatus(req.Msg.GetStatus()), + Limit: int(req.Msg.GetLimit()), BeforeSequence: beforeSequence, + } + if value := req.Msg.GetStartedAfter(); value != nil { + filter.StartedAfter = value.AsTime() + } + if value := req.Msg.GetStartedBefore(); value != nil { + filter.StartedBefore = value.AsTime() + } + result, listErr := h.service.ListAudits(ctx, filter) + if listErr != nil { + return nil, authConnectError(listErr) + } + response := &agentcomposev2.ListOperationAuditsResponse{NextCursor: result.NextCursor} + for _, item := range result.Audits { + response.Audits = append(response.Audits, auditToProto(item)) + } + return connect.NewResponse(response), nil +} + +func authTokenToProto(item controlauth.Token) *agentcomposev2.AuthToken { + result := &agentcomposev2.AuthToken{ + Id: item.ID, Name: item.Name, Description: item.Description, Role: string(item.Role), Origin: item.Origin, + CreatedByTokenId: item.CreatedByTokenID, RevokedByTokenId: item.RevokedByTokenID, + } + if !item.CreatedAt.IsZero() { + result.CreatedAt = timestamppb.New(item.CreatedAt) + } + if !item.RevokedAt.IsZero() { + result.RevokedAt = timestamppb.New(item.RevokedAt) + } + return result +} + +func auditToProto(item controlauth.Audit) *agentcomposev2.OperationAudit { + result := &agentcomposev2.OperationAudit{ + Id: item.ID, Sequence: item.Sequence, RequestId: item.RequestID, TokenId: item.TokenID, TokenName: item.TokenName, + Role: string(item.Role), Origin: item.Origin, Action: item.Action, ResourceType: item.Resource.Type, + ResourceId: item.Resource.ID, ProjectId: item.Resource.ProjectID, Status: string(item.Status), + ParamsJson: item.ParamsJSON, ChangesJson: item.ChangesJSON, ErrorCode: item.ErrorCode, ErrorMessage: item.ErrorMessage, + } + if !item.StartedAt.IsZero() { + result.StartedAt = timestamppb.New(item.StartedAt) + } + if !item.CompletedAt.IsZero() { + result.CompletedAt = timestamppb.New(item.CompletedAt) + } + return result +} + +func parseAuditCursor(value string) (uint64, error) { + value = strings.TrimSpace(value) + if value == "" { + return 0, nil + } + parsed, err := strconv.ParseUint(value, 10, 64) + if err != nil || parsed == 0 { + return 0, errors.New("audit cursor is invalid") + } + return parsed, nil +} + +func authConnectError(err error) error { + switch { + case errors.Is(err, controlauth.ErrInvalidToken): + return connect.NewError(connect.CodeInvalidArgument, err) + case errors.Is(err, controlauth.ErrPermissionDenied): + return connect.NewError(connect.CodePermissionDenied, err) + case errors.Is(err, controlauth.ErrTokenNotFound): + return connect.NewError(connect.CodeNotFound, err) + case errors.Is(err, controlauth.ErrTokenConflict), errors.Is(err, controlauth.ErrIdempotencyConflict): + return connect.NewError(connect.CodeAlreadyExists, err) + case errors.Is(err, controlauth.ErrTokenSelfRevoke), errors.Is(err, controlauth.ErrEnvironmentToken), errors.Is(err, controlauth.ErrLastAdminToken): + return connect.NewError(connect.CodeFailedPrecondition, err) + default: + return connect.NewError(connect.CodeInternal, err) + } +} diff --git a/pkg/agentcompose/api/security_interceptor.go b/pkg/agentcompose/api/security_interceptor.go new file mode 100644 index 000000000..41ca60f8f --- /dev/null +++ b/pkg/agentcompose/api/security_interceptor.go @@ -0,0 +1,422 @@ +package api + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "sync" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" + + controlauth "agent-compose/pkg/auth" + "agent-compose/proto/agentcompose/v2/agentcomposev2connect" +) + +type procedurePolicy struct { + Access controlauth.Access + Action string + SelfAudited bool +} + +type SecurityInterceptor struct{ service *controlauth.Service } + +func NewSecurityInterceptor(service *controlauth.Service) *SecurityInterceptor { + return &SecurityInterceptor{service: service} +} + +func (i *SecurityInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, request connect.AnyRequest) (connect.AnyResponse, error) { + policy, identity, err := i.authorize(ctx, request.Spec().Procedure) + if err != nil { + return nil, err + } + if policy.Access == controlauth.AccessRead || policy.SelfAudited { + return next(ctx, request) + } + audit, err := i.service.BeginAudit(ctx, identity, request.Header().Get("X-Request-ID"), policy.Action, resourceFromMessage(request.Any()), summarizeMessage(request.Any())) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("begin operation audit: %w", err)) + } + response, callErr := next(ctx, request) + status := controlauth.AuditStatusSucceeded + changes := "{}" + resource := resourceFromMessage(request.Any()) + code, message := "", "" + if response != nil { + changes = summarizeMessage(response.Any()) + if resolved := resourceFromMessage(response.Any()); resolved.ID != "" || resolved.ProjectID != "" { + resource = resolved + } + } + if callErr != nil { + status = controlauth.AuditStatusFailed + code = connect.CodeOf(callErr).String() + message = callErr.Error() + } + if finishErr := i.service.FinishAudit(context.WithoutCancel(ctx), audit, status, resource, changes, code, message); finishErr != nil { + if callErr != nil { + return nil, errors.Join(callErr, finishErr) + } + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("finish operation audit: %w", finishErr)) + } + return response, callErr + } +} + +func (i *SecurityInterceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc { + return next +} + +func (i *SecurityInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc { + return func(ctx context.Context, conn connect.StreamingHandlerConn) error { + policy, identity, err := i.authorize(ctx, conn.Spec().Procedure) + if err != nil { + return err + } + if policy.Access == controlauth.AccessRead { + return next(ctx, conn) + } + audit, err := i.service.BeginAudit(ctx, identity, conn.RequestHeader().Get("X-Request-ID"), policy.Action, controlauth.Resource{}, "{}") + if err != nil { + return connect.NewError(connect.CodeInternal, fmt.Errorf("begin operation audit: %w", err)) + } + observed := &auditStreamingConn{StreamingHandlerConn: conn} + callErr := next(ctx, observed) + status := controlauth.AuditStatusSucceeded + code, message := "", "" + if callErr != nil && !errors.Is(callErr, io.EOF) { + status = controlauth.AuditStatusFailed + code = connect.CodeOf(callErr).String() + message = callErr.Error() + } + audit.ParamsJSON = observed.requestSummary() + resource := observed.resource() + if finishErr := i.service.FinishAudit(context.WithoutCancel(ctx), audit, status, resource, observed.responseSummary(), code, message); finishErr != nil { + if callErr != nil { + return errors.Join(callErr, finishErr) + } + return connect.NewError(connect.CodeInternal, fmt.Errorf("finish operation audit: %w", finishErr)) + } + return callErr + } +} + +func (i *SecurityInterceptor) authorize(ctx context.Context, procedure string) (procedurePolicy, controlauth.Identity, error) { + policy, ok := procedurePolicies[procedure] + if !ok { + return procedurePolicy{}, controlauth.Identity{}, connect.NewError(connect.CodeInternal, fmt.Errorf("%w: %s", controlauth.ErrPolicyMissing, procedure)) + } + identity, ok := controlauth.IdentityFromContext(ctx) + if !ok { + if i.service.Initialized() { + return procedurePolicy{}, controlauth.Identity{}, connect.NewError(connect.CodeUnauthenticated, controlauth.ErrInvalidToken) + } + identity = controlauth.Identity{TokenName: "anonymous-admin", Role: controlauth.RoleAdmin, Origin: controlauth.OriginAnonymous} + } + if controlauth.Allowed(identity.Role, policy.Access) { + return policy, identity, nil + } + if policy.Access == controlauth.AccessOperation { + _ = i.service.DenyAudit(context.WithoutCancel(ctx), identity, "", policy.Action) + } + return procedurePolicy{}, controlauth.Identity{}, connect.NewError(connect.CodePermissionDenied, controlauth.ErrPermissionDenied) +} + +var procedurePolicies = buildProcedurePolicies() + +func buildProcedurePolicies() map[string]procedurePolicy { + read := []string{ + agentcomposev2connect.ProjectServiceValidateProjectProcedure, + agentcomposev2connect.ProjectServiceGetProjectProcedure, + agentcomposev2connect.ProjectServiceListProjectsProcedure, + agentcomposev2connect.ProjectServiceWatchProjectProcedure, + agentcomposev2connect.ProjectServiceGetSchedulerProcedure, + agentcomposev2connect.ProjectServiceListSchedulersProcedure, + agentcomposev2connect.ProjectServiceListSchedulerEventsProcedure, + agentcomposev2connect.ProjectServiceGetSchedulerRunProcedure, + agentcomposev2connect.ProjectServiceListSchedulerRunsProcedure, + agentcomposev2connect.RunServiceGetRunProcedure, + agentcomposev2connect.RunServiceListRunsProcedure, + agentcomposev2connect.RunServiceFollowRunLogsProcedure, + agentcomposev2connect.RunServiceListRunEventsProcedure, + agentcomposev2connect.RunServiceListSandboxRunEventsProcedure, + agentcomposev2connect.ImageServiceListImagesProcedure, + agentcomposev2connect.ImageServiceInspectImageProcedure, + agentcomposev2connect.CacheServiceListCachesProcedure, + agentcomposev2connect.CacheServiceInspectCacheProcedure, + agentcomposev2connect.VolumeServiceListVolumesProcedure, + agentcomposev2connect.VolumeServiceInspectVolumeProcedure, + agentcomposev2connect.SandboxServiceGetSandboxStatsProcedure, + agentcomposev2connect.SandboxServiceGetSandboxProcedure, + agentcomposev2connect.SandboxServiceListSandboxesProcedure, + agentcomposev2connect.SandboxServiceListSandboxHistoryProcedure, + agentcomposev2connect.SandboxServiceWatchSandboxProcedure, + agentcomposev2connect.DashboardServiceGetDashboardOverviewProcedure, + agentcomposev2connect.DashboardServiceWatchDashboardOverviewProcedure, + agentcomposev2connect.SettingsServiceGetGlobalEnvProcedure, + agentcomposev2connect.SettingsServiceGetCapabilityGatewayConfigProcedure, + agentcomposev2connect.SettingsServiceListWorkspacePresetsProcedure, + agentcomposev2connect.CapabilityServiceGetCapabilityStatusProcedure, + agentcomposev2connect.CapabilityServiceListCapabilitySetsProcedure, + agentcomposev2connect.CapabilityServiceGetCapabilityCatalogProcedure, + agentcomposev2connect.ResourceServiceResolveIDProcedure, + agentcomposev2connect.AuthServiceWhoAmIProcedure, + agentcomposev2connect.AuthServiceListRolesProcedure, + agentcomposev2connect.AuthServiceListTokensProcedure, + agentcomposev2connect.AuthServiceListOperationAuditsProcedure, + } + operations := map[string]string{ + agentcomposev2connect.ProjectServiceApplyProjectProcedure: "project.apply", + agentcomposev2connect.ProjectServiceRemoveProjectProcedure: "project.remove", + agentcomposev2connect.ProjectServiceRunSchedulerProcedure: "scheduler.run", + agentcomposev2connect.ProjectServiceStartSchedulerRunProcedure: "scheduler.start", + agentcomposev2connect.ProjectServiceStopSchedulerRunProcedure: "scheduler.stop", + agentcomposev2connect.ProjectServiceSetSchedulerEnabledProcedure: "scheduler.enable", + agentcomposev2connect.ProjectServiceSetSchedulerTriggerEnabledProcedure: "scheduler.trigger.enable", + agentcomposev2connect.RunServiceRunAgentProcedure: "run.agent", + agentcomposev2connect.RunServiceStartRunProcedure: "run.start", + agentcomposev2connect.RunServiceRunAgentStreamProcedure: "run.agent.stream", + agentcomposev2connect.RunServiceRunAttachProcedure: "run.attach", + agentcomposev2connect.RunServiceStopRunProcedure: "run.stop", + agentcomposev2connect.ExecServiceExecProcedure: "exec.execute", + agentcomposev2connect.ExecServiceExecStreamProcedure: "exec.stream", + agentcomposev2connect.ExecServiceExecAttachProcedure: "exec.attach", + agentcomposev2connect.ImageServicePullImageProcedure: "image.pull", + agentcomposev2connect.ImageServiceRemoveImageProcedure: "image.remove", + agentcomposev2connect.ImageServiceBuildImageProcedure: "image.build", + agentcomposev2connect.CacheServicePruneCachesProcedure: "cache.prune", + agentcomposev2connect.CacheServiceRemoveCacheProcedure: "cache.remove", + agentcomposev2connect.VolumeServiceCreateVolumeProcedure: "volume.create", + agentcomposev2connect.VolumeServiceRemoveVolumeProcedure: "volume.remove", + agentcomposev2connect.VolumeServicePruneVolumesProcedure: "volume.prune", + agentcomposev2connect.SandboxServiceRemoveSandboxProcedure: "sandbox.remove", + agentcomposev2connect.SandboxServicePruneSandboxesProcedure: "sandbox.prune", + agentcomposev2connect.SandboxServiceStopSandboxProcedure: "sandbox.stop", + agentcomposev2connect.SandboxServiceResumeSandboxProcedure: "sandbox.resume", + agentcomposev2connect.SettingsServiceUpdateGlobalEnvProcedure: "settings.global-env.update", + agentcomposev2connect.SettingsServiceUpdateCapabilityGatewayConfigProcedure: "settings.capability-gateway.update", + agentcomposev2connect.SettingsServiceCreateWorkspacePresetProcedure: "workspace-preset.create", + agentcomposev2connect.SettingsServiceUpdateWorkspacePresetProcedure: "workspace-preset.update", + agentcomposev2connect.SettingsServiceDeleteWorkspacePresetProcedure: "workspace-preset.delete", + agentcomposev2connect.LLMServiceGenerateProcedure: "llm.generate", + agentcomposev2connect.AuthServiceCreateTokenProcedure: "auth.token.create", + agentcomposev2connect.AuthServiceRevokeTokenProcedure: "auth.token.revoke", + } + result := make(map[string]procedurePolicy, len(read)+len(operations)) + for _, procedure := range read { + result[procedure] = procedurePolicy{Access: controlauth.AccessRead} + } + for procedure, action := range operations { + result[procedure] = procedurePolicy{Access: controlauth.AccessOperation, Action: action} + } + result[agentcomposev2connect.AuthServiceCreateTokenProcedure] = procedurePolicy{Access: controlauth.AccessOperation, Action: "auth.token.create", SelfAudited: true} + result[agentcomposev2connect.AuthServiceRevokeTokenProcedure] = procedurePolicy{Access: controlauth.AccessOperation, Action: "auth.token.revoke", SelfAudited: true} + return result +} + +type auditStreamingConn struct { + connect.StreamingHandlerConn + mu sync.Mutex + requests []string + responses []string + resourceRef controlauth.Resource +} + +func (c *auditStreamingConn) Receive(message any) error { + if err := c.StreamingHandlerConn.Receive(message); err != nil { + return err + } + c.mu.Lock() + c.requests = appendLimited(c.requests, summarizeMessage(message)) + if resource := resourceFromMessage(message); resource.ID != "" || resource.ProjectID != "" { + c.resourceRef = resource + } + c.mu.Unlock() + return nil +} + +func (c *auditStreamingConn) Send(message any) error { + c.mu.Lock() + c.responses = appendLimited(c.responses, summarizeMessage(message)) + if resource := resourceFromMessage(message); resource.ID != "" || resource.ProjectID != "" { + c.resourceRef = resource + } + c.mu.Unlock() + return c.StreamingHandlerConn.Send(message) +} + +func (c *auditStreamingConn) requestSummary() string { + c.mu.Lock() + defer c.mu.Unlock() + return marshalSummary(map[string]any{"messages": c.requests}) +} + +func (c *auditStreamingConn) responseSummary() string { + c.mu.Lock() + defer c.mu.Unlock() + return marshalSummary(map[string]any{"messages": c.responses}) +} + +func (c *auditStreamingConn) resource() controlauth.Resource { + c.mu.Lock() + defer c.mu.Unlock() + return c.resourceRef +} + +func appendLimited(items []string, value string) []string { + if len(items) >= 20 { + return items + } + return append(items, value) +} + +var auditFieldAllowlist = map[protoreflect.Name]bool{ + "project_id": true, "name": true, "agent_name": true, "scheduler_id": true, "trigger_id": true, + "run_id": true, "sandbox_id": true, "image_ref": true, "cache_id": true, "driver": true, + "cwd": true, "command": true, "args": true, "force": true, "remove_history": true, + "stop_running_sandboxes": true, "cleanup_policy": true, "client_request_id": true, + "description": true, "role": true, "older_than_seconds": true, "include_orphans": true, + "query": true, "status": true, "target": true, "tags": true, "no_cache": true, "pull": true, + "preset_id": true, "enabled": true, "dry_run": true, "created": true, "removed": true, + "revoked": true, "already_revoked": true, "started": true, "stop_requested": true, + "resource_type": true, "resource_id": true, "action": true, "exit_code": true, +} + +func summarizeMessage(value any) string { + message, ok := value.(interface{ ProtoReflect() protoreflect.Message }) + if !ok { + return "{}" + } + return marshalSummary(summarizeReflectMessage(message.ProtoReflect(), 0)) +} + +func summarizeReflectMessage(message protoreflect.Message, depth int) map[string]any { + if !message.IsValid() || depth > 4 { + return map[string]any{} + } + result := make(map[string]any) + message.Range(func(field protoreflect.FieldDescriptor, value protoreflect.Value) bool { + name := field.Name() + if name == "token" || name == "password" || name == "value" || name == "env" || name == "headers" || name == "build_args" || name == "options" { + return true + } + if name == "prompt" || name == "human_message" || name == "payload_json" { + text := value.String() + digest := sha256.Sum256([]byte(text)) + result[string(name)+"_length"] = len(text) + result[string(name)+"_sha256"] = hex.EncodeToString(digest[:]) + return true + } + if !auditFieldAllowlist[name] && field.Kind() != protoreflect.MessageKind { + return true + } + if field.IsList() { + list := value.List() + items := make([]any, 0, min(list.Len(), 20)) + for index := 0; index < list.Len() && index < 20; index++ { + items = append(items, summarizeReflectValue(field, list.Get(index), depth+1)) + } + result[string(name)] = items + return true + } + if field.IsMap() { + return true + } + if field.Kind() == protoreflect.MessageKind { + nested := summarizeReflectMessage(value.Message(), depth+1) + if len(nested) > 0 { + result[string(name)] = nested + } + return true + } + result[string(name)] = summarizeReflectValue(field, value, depth+1) + return true + }) + return result +} + +func summarizeReflectValue(field protoreflect.FieldDescriptor, value protoreflect.Value, depth int) any { + switch field.Kind() { + case protoreflect.StringKind: + text := value.String() + if len(text) > 4096 { + return text[:4096] + "…" + } + return text + case protoreflect.BoolKind: + return value.Bool() + case protoreflect.EnumKind: + return string(field.Enum().Values().ByNumber(value.Enum()).Name()) + case protoreflect.Int32Kind, protoreflect.Int64Kind, protoreflect.Sint32Kind, protoreflect.Sint64Kind, + protoreflect.Sfixed32Kind, protoreflect.Sfixed64Kind: + return value.Int() + case protoreflect.Uint32Kind, protoreflect.Uint64Kind, protoreflect.Fixed32Kind, protoreflect.Fixed64Kind: + return value.Uint() + case protoreflect.FloatKind, protoreflect.DoubleKind: + return value.Float() + case protoreflect.MessageKind: + return summarizeReflectMessage(value.Message(), depth) + default: + return nil + } +} + +func marshalSummary(value any) string { + data, err := json.Marshal(value) + if err != nil || len(data) > 16*1024 { + return `{"truncated":true}` + } + return string(data) +} + +func resourceFromMessage(value any) controlauth.Resource { + modern, ok := value.(interface{ ProtoReflect() protoreflect.Message }) + if !ok { + return controlauth.Resource{} + } + return findResource(modern.ProtoReflect(), 0) +} + +func findResource(message protoreflect.Message, depth int) controlauth.Resource { + if !message.IsValid() || depth > 4 { + return controlauth.Resource{} + } + resource := controlauth.Resource{} + fields := message.Descriptor().Fields() + for _, candidate := range []struct { + name, resourceType string + }{{"project_id", "project"}, {"run_id", "run"}, {"sandbox_id", "sandbox"}, {"image_ref", "image"}, {"cache_id", "cache"}, {"preset_id", "workspace-preset"}} { + field := fields.ByName(protoreflect.Name(candidate.name)) + if field == nil || !message.Has(field) || field.Kind() != protoreflect.StringKind { + continue + } + value := strings.TrimSpace(message.Get(field).String()) + if value == "" { + continue + } + if candidate.name == "project_id" { + resource.ProjectID = value + } + if resource.ID == "" { + resource.Type, resource.ID = candidate.resourceType, value + } + } + if resource.ID != "" { + return resource + } + var nested controlauth.Resource + message.Range(func(field protoreflect.FieldDescriptor, value protoreflect.Value) bool { + if field.Kind() != protoreflect.MessageKind || field.IsList() || field.IsMap() { + return true + } + nested = findResource(value.Message(), depth+1) + return nested.ID == "" + }) + return nested +} diff --git a/pkg/agentcompose/api/security_interceptor_test.go b/pkg/agentcompose/api/security_interceptor_test.go new file mode 100644 index 000000000..f2a9b1d56 --- /dev/null +++ b/pkg/agentcompose/api/security_interceptor_test.go @@ -0,0 +1,53 @@ +package api + +import ( + "strings" + "testing" + + agentcomposev2 "agent-compose/proto/agentcompose/v2" + "google.golang.org/protobuf/reflect/protoreflect" +) + +func TestEveryV2ProcedureHasSecurityPolicy(t *testing.T) { + services := agentcomposev2.File_agentcompose_v2_agentcompose_proto.Services() + for serviceIndex := 0; serviceIndex < services.Len(); serviceIndex++ { + service := services.Get(serviceIndex) + methods := service.Methods() + for methodIndex := 0; methodIndex < methods.Len(); methodIndex++ { + procedure := "/" + string(service.FullName()) + "/" + string(methods.Get(methodIndex).Name()) + if _, ok := procedurePolicies[procedure]; !ok { + t.Errorf("procedure %s has no security policy", procedure) + } + } + } +} + +func TestAuditSummaryRedactsTokenAndSensitiveContent(t *testing.T) { + request := &agentcomposev2.CreateTokenRequest{ + Name: "reader", Description: "safe", Role: "read-only-admin", Token: "ac_plaintext_must_not_appear", ClientRequestId: "request-1", + } + summary := summarizeMessage(request) + if strings.Contains(summary, request.GetToken()) || strings.Contains(summary, `"token"`) { + t.Fatalf("summary leaked token: %s", summary) + } + if !strings.Contains(summary, `"name":"reader"`) || !strings.Contains(summary, `"client_request_id":"request-1"`) { + t.Fatalf("summary omitted safe fields: %s", summary) + } +} + +func TestTokenAPIResponseDescriptorsCannotCarrySecretOrHash(t *testing.T) { + for _, message := range []protoreflect.MessageDescriptor{ + (&agentcomposev2.AuthToken{}).ProtoReflect().Descriptor(), + (&agentcomposev2.CreateTokenResponse{}).ProtoReflect().Descriptor(), + (&agentcomposev2.RevokeTokenResponse{}).ProtoReflect().Descriptor(), + (&agentcomposev2.ListTokensResponse{}).ProtoReflect().Descriptor(), + } { + fields := message.Fields() + for index := 0; index < fields.Len(); index++ { + name := strings.ToLower(string(fields.Get(index).Name())) + if name == "token" || strings.Contains(name, "secret") || strings.Contains(name, "hash") || strings.Contains(name, "fingerprint") { + t.Errorf("response message %s exposes forbidden field %s", message.FullName(), name) + } + } + } +} diff --git a/pkg/agentcompose/app/app.go b/pkg/agentcompose/app/app.go index 0a6acdf76..e5c2e7adc 100644 --- a/pkg/agentcompose/app/app.go +++ b/pkg/agentcompose/app/app.go @@ -9,12 +9,14 @@ import ( "strings" "time" + "connectrpc.com/connect" "github.com/labstack/echo/v4" "github.com/samber/do/v2" "agent-compose/pkg/agentcompose/adapters" "agent-compose/pkg/agentcompose/api" "agent-compose/pkg/agentcompose/proxy" + controlauth "agent-compose/pkg/auth" "agent-compose/pkg/cache" "agent-compose/pkg/capabilities" "agent-compose/pkg/capproxy" @@ -54,6 +56,7 @@ func RegisterDependencies(di do.Injector) { do.Provide(di, func(do.Injector) (*sessions.LifecycleLocks, error) { return sessions.NewLifecycleLocks(), nil }) do.Provide(di, sessionstore.NewStore) do.Provide(di, NewConfigStore) + do.Provide(di, NewAuthService) do.Provide(di, NewWorkspaceProvisioner) do.MustAs[*workspaces.Provisioner, workspaces.WorkspaceEnsurer](di) do.Provide(di, NewRuntimeProvider) @@ -89,20 +92,22 @@ func RegisterDependencies(di do.Injector) { func RegisterRoutes(di do.Injector) { app := do.MustInvoke[*echo.Echo](di) + security := api.NewSecurityInterceptor(do.MustInvoke[*controlauth.Service](di)) + handlerOptions := []connect.HandlerOption{connect.WithInterceptors(security)} projectHandler := api.NewProjectHandler( projectControllerDelegate{controller: do.MustInvoke[*projects.Controller](di)}, do.MustInvoke[*configstore.ConfigStore](di), do.MustInvoke[*loaders.Controller](di), ) - path, handler := agentcomposev2connect.NewProjectServiceHandler(projectHandler) + path, handler := agentcomposev2connect.NewProjectServiceHandler(projectHandler, handlerOptions...) app.Any(path+"*", echo.WrapHandler(handler)) runDelegate := runControllerDelegate{ controller: do.MustInvoke[*runs.Controller](di), supervisor: do.MustInvoke[*RunSupervisor](di), } runHandler := api.NewRunHandlerWithRunLogHub(runDelegate, do.MustInvoke[*configstore.ConfigStore](di), do.MustInvoke[*runs.RunLogHub](di), do.MustInvoke[*RunSupervisor](di)) - path, handler = agentcomposev2connect.NewRunServiceHandler(runHandler) + path, handler = agentcomposev2connect.NewRunServiceHandler(runHandler, handlerOptions...) app.Any(path+"*", echo.WrapHandler(handler)) execHandler := api.NewExecHandler( do.MustInvoke[*appconfig.Config](di), @@ -113,16 +118,16 @@ func RegisterRoutes(di do.Injector) { }, runDelegate, ).WithLifecycleLocks(do.MustInvoke[*sessions.LifecycleLocks](di)) - path, handler = agentcomposev2connect.NewExecServiceHandler(execHandler) + path, handler = agentcomposev2connect.NewExecServiceHandler(execHandler, handlerOptions...) app.Any(path+"*", echo.WrapHandler(handler)) imageHandler := api.NewImageHandler(do.MustInvoke[*adapters.ImageBackends](di)) - path, handler = agentcomposev2connect.NewImageServiceHandler(imageHandler) + path, handler = agentcomposev2connect.NewImageServiceHandler(imageHandler, handlerOptions...) app.Any(path+"*", echo.WrapHandler(handler)) cacheHandler := api.NewCacheHandler(do.MustInvoke[*cache.Controller](di)) - path, handler = agentcomposev2connect.NewCacheServiceHandler(cacheHandler) + path, handler = agentcomposev2connect.NewCacheServiceHandler(cacheHandler, handlerOptions...) app.Any(path+"*", echo.WrapHandler(handler)) volumeHandler := api.NewVolumeHandler(do.MustInvoke[*volumes.Manager](di)) - path, handler = agentcomposev2connect.NewVolumeServiceHandler(volumeHandler) + path, handler = agentcomposev2connect.NewVolumeServiceHandler(volumeHandler, handlerOptions...) app.Any(path+"*", echo.WrapHandler(handler)) sandboxHandler := api.NewSandboxHandler( do.MustInvoke[*adapters.SandboxRPCBridge](di), @@ -141,18 +146,21 @@ func RegisterRoutes(di do.Injector) { return statsRuntime, nil }, ).WithRunTargetResolver(do.MustInvoke[*runs.SandboxRunTargetResolver](di)).WithRemovalCoordinator(do.MustInvoke[*sessions.RemovalCoordinator](di)) - path, handler = agentcomposev2connect.NewSandboxServiceHandler(sandboxHandler) + path, handler = agentcomposev2connect.NewSandboxServiceHandler(sandboxHandler, handlerOptions...) app.Any(path+"*", echo.WrapHandler(handler)) - path, handler = agentcomposev2connect.NewSettingsServiceHandler(api.NewSettingsV2Handler(do.MustInvoke[*appconfig.Config](di), do.MustInvoke[*configstore.ConfigStore](di))) + path, handler = agentcomposev2connect.NewSettingsServiceHandler(api.NewSettingsV2Handler(do.MustInvoke[*appconfig.Config](di), do.MustInvoke[*configstore.ConfigStore](di)), handlerOptions...) app.Any(path+"*", echo.WrapHandler(handler)) - path, handler = agentcomposev2connect.NewDashboardServiceHandler(api.NewDashboardV2Handler(do.MustInvoke[*dashboard.Hub](di))) + path, handler = agentcomposev2connect.NewDashboardServiceHandler(api.NewDashboardV2Handler(do.MustInvoke[*dashboard.Hub](di)), handlerOptions...) app.Any(path+"*", echo.WrapHandler(handler)) - path, handler = agentcomposev2connect.NewCapabilityServiceHandler(api.NewCapabilityV2Handler(do.MustInvoke[capabilities.Provider](di), capabilityRuntimeConfig{config: do.MustInvoke[*appconfig.Config](di)})) + path, handler = agentcomposev2connect.NewCapabilityServiceHandler(api.NewCapabilityV2Handler(do.MustInvoke[capabilities.Provider](di), capabilityRuntimeConfig{config: do.MustInvoke[*appconfig.Config](di)}), handlerOptions...) app.Any(path+"*", echo.WrapHandler(handler)) - path, handler = agentcomposev2connect.NewLLMServiceHandler(api.NewLLMHandler(do.MustInvoke[*adapters.LLMClient](di))) + path, handler = agentcomposev2connect.NewLLMServiceHandler(api.NewLLMHandler(do.MustInvoke[*adapters.LLMClient](di)), handlerOptions...) app.Any(path+"*", echo.WrapHandler(handler)) resourceHandler := api.NewResourceHandler(do.MustInvoke[*resources.Locator](di)) - path, handler = agentcomposev2connect.NewResourceServiceHandler(resourceHandler) + path, handler = agentcomposev2connect.NewResourceServiceHandler(resourceHandler, handlerOptions...) + app.Any(path+"*", echo.WrapHandler(handler)) + authHandler := api.NewAuthHandler(do.MustInvoke[*controlauth.Service](di)) + path, handler = agentcomposev2connect.NewAuthServiceHandler(authHandler, handlerOptions...) app.Any(path+"*", echo.WrapHandler(handler)) registerProxyRoutes(app, di) @@ -387,6 +395,14 @@ func NewConfigStore(di do.Injector) (*configstore.ConfigStore, error) { return configstore.NewConfigStore(di) } +func NewAuthService(di do.Injector) (*controlauth.Service, error) { + return controlauth.NewService( + context.WithoutCancel(do.MustInvoke[context.Context](di)), + do.MustInvoke[*configstore.ConfigStore](di), + do.MustInvoke[*appconfig.Config](di).DaemonAuthToken, + ) +} + func NewWorkspaceProvisioner(di do.Injector) (*workspaces.Provisioner, error) { return workspaces.NewProvisioner( do.MustInvoke[*appconfig.Config](di), diff --git a/pkg/auth/context.go b/pkg/auth/context.go new file mode 100644 index 000000000..23b533c16 --- /dev/null +++ b/pkg/auth/context.go @@ -0,0 +1,14 @@ +package auth + +import "context" + +type identityContextKey struct{} + +func WithIdentity(ctx context.Context, identity Identity) context.Context { + return context.WithValue(ctx, identityContextKey{}, identity) +} + +func IdentityFromContext(ctx context.Context) (Identity, bool) { + identity, ok := ctx.Value(identityContextKey{}).(Identity) + return identity, ok +} diff --git a/pkg/auth/model.go b/pkg/auth/model.go new file mode 100644 index 000000000..ef20dcc0a --- /dev/null +++ b/pkg/auth/model.go @@ -0,0 +1,171 @@ +// Package auth owns daemon control-plane token authentication, role +// authorization, token lifecycle, and operation audit records. +package auth + +import ( + "context" + "errors" + "time" +) + +type Role string + +const ( + RoleAdmin Role = "admin" + RoleReadOnlyAdmin Role = "read-only-admin" +) + +type Access string + +const ( + AccessRead Access = "read" + AccessOperation Access = "operation" +) + +const ( + OriginEnvironment = "environment" + OriginIssued = "issued" + OriginLocal = "local" + OriginAnonymous = "anonymous" + OriginSystem = "system" + + DefaultAdminName = "default-admin" +) + +var ( + ErrInvalidToken = errors.New("invalid daemon token") + ErrPermissionDenied = errors.New("daemon operation is not permitted") + ErrPolicyMissing = errors.New("daemon operation policy is missing") + ErrTokenNotFound = errors.New("daemon token not found") + ErrTokenConflict = errors.New("daemon token conflicts with existing data") + ErrTokenSelfRevoke = errors.New("a token cannot revoke itself") + ErrEnvironmentToken = errors.New("environment-managed token cannot be revoked") + ErrLastAdminToken = errors.New("last active admin token cannot be revoked remotely") + ErrIdempotencyConflict = errors.New("token create request conflicts with an earlier request") +) + +type Token struct { + ID string + Name string + Description string + Role Role + Origin string + Fingerprint string + CreatedByTokenID string + ClientRequestID string + CreatedAt time.Time + RevokedByTokenID string + RevokedAt time.Time +} + +func (t Token) Active() bool { return t.RevokedAt.IsZero() } + +type Identity struct { + TokenID string + TokenName string + Role Role + Origin string +} + +func (i Identity) IsLocal() bool { return i.Origin == OriginLocal } + +func (i Identity) TokenSummary() Token { + return Token{ID: i.TokenID, Name: i.TokenName, Role: i.Role, Origin: i.Origin} +} + +type RoleInfo struct { + Name Role + Description string + ReadOnly bool + Builtin bool +} + +func Roles() []RoleInfo { + return []RoleInfo{ + {Name: RoleAdmin, Description: "Full daemon control-plane access", Builtin: true}, + {Name: RoleReadOnlyAdmin, Description: "Read-only access to all daemon resources and logs", ReadOnly: true, Builtin: true}, + } +} + +type CreateTokenInput struct { + Name string + Description string + Role Role + Plaintext string + ClientRequestID string +} + +type TokenListOptions struct { + IncludeRevoked bool + Limit int + BeforeID string +} + +type TokenListResult struct { + Tokens []Token + NextCursor string +} + +type AuditStatus string + +const ( + AuditStatusStarted AuditStatus = "started" + AuditStatusSucceeded AuditStatus = "succeeded" + AuditStatusFailed AuditStatus = "failed" + AuditStatusDenied AuditStatus = "denied" +) + +type Resource struct { + Type string + ID string + ProjectID string +} + +type Audit struct { + Sequence uint64 + ID string + RequestID string + TokenID string + TokenName string + Role Role + Origin string + Action string + Resource Resource + Status AuditStatus + ParamsJSON string + ChangesJSON string + ErrorCode string + ErrorMessage string + StartedAt time.Time + CompletedAt time.Time +} + +type AuditFilter struct { + Token string + Action string + ResourceType string + ResourceID string + ProjectID string + Status AuditStatus + StartedAfter time.Time + StartedBefore time.Time + Limit int + BeforeSequence uint64 +} + +type AuditListResult struct { + Audits []Audit + NextCursor string +} + +type Store interface { + ReconcileEnvironmentToken(context.Context, string, time.Time) (Token, bool, error) + AuthInitialized(context.Context) (bool, error) + AuthenticateTokenHash(context.Context, string) (Token, error) + ListTokens(context.Context, TokenListOptions) (TokenListResult, error) + CreateToken(context.Context, Identity, CreateTokenInput, string, string, time.Time) (Token, bool, bool, error) + RevokeToken(context.Context, Identity, string, string, string, time.Time) (Token, bool, bool, error) + BeginAudit(context.Context, Audit) error + FinishAudit(context.Context, Audit) error + ListAudits(context.Context, AuditFilter) (AuditListResult, error) +} diff --git a/pkg/auth/service.go b/pkg/auth/service.go new file mode 100644 index 000000000..582083fa5 --- /dev/null +++ b/pkg/auth/service.go @@ -0,0 +1,261 @@ +package auth + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "regexp" + "strings" + "sync" + "sync/atomic" + "time" + "unicode/utf8" + + "agent-compose/pkg/identity" +) + +var ( + tokenNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`) + issuedTokenPattern = regexp.MustCompile(`^ac_[A-Za-z0-9_-]{43}$`) +) + +type Service struct { + store Store + initialized atomic.Bool + now func() time.Time + requests requestRegistry +} + +func NewService(ctx context.Context, store Store, environmentToken string) (*Service, error) { + if store == nil { + return nil, fmt.Errorf("auth store is required") + } + service := &Service{store: store, now: func() time.Time { return time.Now().UTC() }} + if _, _, err := store.ReconcileEnvironmentToken(ctx, strings.TrimSpace(environmentToken), service.now()); err != nil { + return nil, fmt.Errorf("reconcile AGENT_COMPOSE_AUTH_TOKEN: %w", err) + } + initialized, err := store.AuthInitialized(ctx) + if err != nil { + return nil, fmt.Errorf("check daemon authentication state: %w", err) + } + service.initialized.Store(initialized) + return service, nil +} + +func (s *Service) Initialized() bool { + return s != nil && s.initialized.Load() +} + +func (s *Service) Authenticate(ctx context.Context, plaintext string) (Identity, error) { + if s == nil || s.store == nil { + return Identity{}, fmt.Errorf("auth service is unavailable") + } + plaintext = strings.TrimSpace(plaintext) + if plaintext == "" || strings.ContainsAny(plaintext, " \t\r\n") { + return Identity{}, ErrInvalidToken + } + token, err := s.store.AuthenticateTokenHash(ctx, TokenHash(plaintext)) + if err != nil { + return Identity{}, err + } + return Identity{TokenID: token.ID, TokenName: token.Name, Role: token.Role, Origin: token.Origin}, nil +} + +func (s *Service) CreateToken(ctx context.Context, actor Identity, input CreateTokenInput, requestID string) (Token, bool, bool, error) { + input.Name = strings.TrimSpace(input.Name) + input.Description = strings.TrimSpace(input.Description) + input.Plaintext = strings.TrimSpace(input.Plaintext) + input.ClientRequestID = strings.TrimSpace(input.ClientRequestID) + if actor.Role != RoleAdmin { + return Token{}, false, false, ErrPermissionDenied + } + if !tokenNamePattern.MatchString(input.Name) || strings.EqualFold(input.Name, DefaultAdminName) { + return Token{}, false, false, fmt.Errorf("%w: token name must match %s and must not be %q", ErrInvalidToken, tokenNamePattern.String(), DefaultAdminName) + } + if utf8.RuneCountInString(input.Description) > 512 { + return Token{}, false, false, fmt.Errorf("%w: token description exceeds 512 characters", ErrInvalidToken) + } + if input.Role != RoleAdmin && input.Role != RoleReadOnlyAdmin { + return Token{}, false, false, fmt.Errorf("%w: unsupported role %q", ErrInvalidToken, input.Role) + } + if !issuedTokenPattern.MatchString(input.Plaintext) { + return Token{}, false, false, fmt.Errorf("%w: client-generated token has an invalid format", ErrInvalidToken) + } + if input.ClientRequestID == "" || len(input.ClientRequestID) > 128 || strings.ContainsAny(input.ClientRequestID, " \t\r\n") { + return Token{}, false, false, fmt.Errorf("%w: client_request_id is required and must not contain whitespace", ErrInvalidToken) + } + params := marshalAuditJSON(map[string]any{ + "name": input.Name, "description": input.Description, "role": input.Role, + "client_request_id": input.ClientRequestID, + }) + item, created, replay, err := s.store.CreateToken(ctx, actor, input, normalizeRequestID(requestID), params, s.now()) + if err == nil { + s.initialized.Store(true) + } + return item, created, replay, err +} + +func (s *Service) RevokeToken(ctx context.Context, actor Identity, ref, requestID string) (Token, bool, bool, error) { + if actor.Role != RoleAdmin { + return Token{}, false, false, ErrPermissionDenied + } + ref = strings.TrimSpace(ref) + if ref == "" { + return Token{}, false, false, fmt.Errorf("%w: token id or name is required", ErrInvalidToken) + } + params := marshalAuditJSON(map[string]any{"token": ref}) + item, revoked, alreadyRevoked, err := s.store.RevokeToken(ctx, actor, ref, normalizeRequestID(requestID), params, s.now()) + if err == nil && revoked { + s.requests.cancelToken(item.ID) + } + return item, revoked, alreadyRevoked, err +} + +func (s *Service) ListTokens(ctx context.Context, options TokenListOptions) (TokenListResult, error) { + return s.store.ListTokens(ctx, options) +} + +func (s *Service) ListAudits(ctx context.Context, filter AuditFilter) (AuditListResult, error) { + return s.store.ListAudits(ctx, filter) +} + +func (s *Service) BeginAudit(ctx context.Context, actor Identity, requestID, action string, resource Resource, paramsJSON string) (Audit, error) { + now := s.now() + audit := Audit{ + ID: identity.NewRandomID(identity.ResourceAudit), RequestID: normalizeRequestID(requestID), + TokenID: actor.TokenID, TokenName: actor.TokenName, Role: actor.Role, Origin: actor.Origin, + Action: strings.TrimSpace(action), Resource: resource, Status: AuditStatusStarted, + ParamsJSON: normalizeJSONObject(paramsJSON), ChangesJSON: "{}", StartedAt: now, + } + if err := s.store.BeginAudit(ctx, audit); err != nil { + return Audit{}, err + } + return audit, nil +} + +func (s *Service) DenyAudit(ctx context.Context, actor Identity, requestID, action string) error { + now := s.now() + return s.store.BeginAudit(ctx, Audit{ + ID: identity.NewRandomID(identity.ResourceAudit), RequestID: normalizeRequestID(requestID), + TokenID: actor.TokenID, TokenName: actor.TokenName, Role: actor.Role, Origin: actor.Origin, + Action: strings.TrimSpace(action), Status: AuditStatusDenied, ParamsJSON: "{}", ChangesJSON: "{}", + ErrorCode: "permission_denied", ErrorMessage: "operation is not permitted for this role", + StartedAt: now, CompletedAt: now, + }) +} + +func (s *Service) FinishAudit(ctx context.Context, audit Audit, status AuditStatus, resource Resource, changesJSON, errorCode, errorMessage string) error { + audit.Status = status + audit.Resource = resource + audit.ChangesJSON = normalizeJSONObject(changesJSON) + audit.ErrorCode = strings.TrimSpace(errorCode) + audit.ErrorMessage = SanitizeError(errorMessage) + audit.CompletedAt = s.now() + return s.store.FinishAudit(ctx, audit) +} + +func (s *Service) RegisterRequest(tokenID string, cancel context.CancelFunc) func() { + return s.requests.register(strings.TrimSpace(tokenID), cancel) +} + +func TokenHash(plaintext string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(plaintext))) + return hex.EncodeToString(sum[:]) +} + +func TokenFingerprint(plaintext string) string { + hash := TokenHash(plaintext) + if len(hash) < 12 { + return hash + } + return hash[:12] +} + +func ValidRole(role Role) bool { return role == RoleAdmin || role == RoleReadOnlyAdmin } + +func Allowed(role Role, access Access) bool { + return role == RoleAdmin || role == RoleReadOnlyAdmin && access == AccessRead +} + +func normalizeRequestID(value string) string { + value = strings.TrimSpace(value) + if value != "" && len(value) <= 128 && !strings.ContainsAny(value, "\r\n") { + return value + } + return identity.NewRandomID(identity.ResourceAudit) +} + +func marshalAuditJSON(value any) string { + data, err := json.Marshal(value) + if err != nil || len(data) > 16*1024 { + return `{"truncated":true}` + } + return string(data) +} + +func normalizeJSONObject(value string) string { + value = strings.TrimSpace(value) + if value == "" || len(value) > 16*1024 || !json.Valid([]byte(value)) { + return "{}" + } + return value +} + +func SanitizeError(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + for _, prefix := range []string{"Bearer ", "bearer ", "Authorization:", "authorization:"} { + if index := strings.Index(value, prefix); index >= 0 { + value = value[:index] + prefix + "[REDACTED]" + } + } + if len(value) > 2048 { + value = value[:2048] + "…" + } + return value +} + +type requestRegistry struct { + mu sync.Mutex + nextID uint64 + items map[string]map[uint64]context.CancelFunc +} + +func (r *requestRegistry) register(tokenID string, cancel context.CancelFunc) func() { + if tokenID == "" || cancel == nil { + return func() {} + } + r.mu.Lock() + if r.items == nil { + r.items = make(map[string]map[uint64]context.CancelFunc) + } + r.nextID++ + id := r.nextID + if r.items[tokenID] == nil { + r.items[tokenID] = make(map[uint64]context.CancelFunc) + } + r.items[tokenID][id] = cancel + r.mu.Unlock() + return func() { + r.mu.Lock() + delete(r.items[tokenID], id) + if len(r.items[tokenID]) == 0 { + delete(r.items, tokenID) + } + r.mu.Unlock() + } +} + +func (r *requestRegistry) cancelToken(tokenID string) { + r.mu.Lock() + items := r.items[tokenID] + delete(r.items, tokenID) + r.mu.Unlock() + for _, cancel := range items { + cancel() + } +} diff --git a/pkg/auth/service_test.go b/pkg/auth/service_test.go new file mode 100644 index 000000000..b04df7811 --- /dev/null +++ b/pkg/auth/service_test.go @@ -0,0 +1,75 @@ +package auth + +import ( + "context" + "errors" + "testing" + "time" +) + +type serviceStoreFake struct { + initialized bool + created CreateTokenInput +} + +func (s *serviceStoreFake) ReconcileEnvironmentToken(context.Context, string, time.Time) (Token, bool, error) { + return Token{}, false, nil +} +func (s *serviceStoreFake) AuthInitialized(context.Context) (bool, error) { return s.initialized, nil } +func (s *serviceStoreFake) AuthenticateTokenHash(context.Context, string) (Token, error) { + return Token{}, ErrInvalidToken +} +func (s *serviceStoreFake) ListTokens(context.Context, TokenListOptions) (TokenListResult, error) { + return TokenListResult{}, nil +} +func (s *serviceStoreFake) CreateToken(_ context.Context, actor Identity, input CreateTokenInput, _, _ string, now time.Time) (Token, bool, bool, error) { + s.created = input + return Token{ID: "token-1", Name: input.Name, Role: input.Role, CreatedByTokenID: actor.TokenID, CreatedAt: now}, true, false, nil +} +func (s *serviceStoreFake) RevokeToken(context.Context, Identity, string, string, string, time.Time) (Token, bool, bool, error) { + return Token{}, false, false, nil +} +func (s *serviceStoreFake) BeginAudit(context.Context, Audit) error { return nil } +func (s *serviceStoreFake) FinishAudit(context.Context, Audit) error { return nil } +func (s *serviceStoreFake) ListAudits(context.Context, AuditFilter) (AuditListResult, error) { + return AuditListResult{}, nil +} + +func TestCreateTokenRequiresAdminAndClientGeneratedSecret(t *testing.T) { + store := &serviceStoreFake{} + service, err := NewService(context.Background(), store, "") + if err != nil { + t.Fatal(err) + } + valid := CreateTokenInput{Name: "reader", Role: RoleReadOnlyAdmin, Plaintext: "ac_0123456789012345678901234567890123456789012", ClientRequestID: "request-1"} + if _, _, _, err := service.CreateToken(context.Background(), Identity{Role: RoleReadOnlyAdmin}, valid, ""); !errors.Is(err, ErrPermissionDenied) { + t.Fatalf("read-only create error = %v, want permission denied", err) + } + invalid := valid + invalid.Plaintext = "server-please-generate" + if _, _, _, err := service.CreateToken(context.Background(), Identity{Role: RoleAdmin}, invalid, ""); !errors.Is(err, ErrInvalidToken) { + t.Fatalf("invalid secret error = %v, want invalid token", err) + } + item, created, replay, err := service.CreateToken(context.Background(), Identity{TokenID: "admin-1", Role: RoleAdmin}, valid, "request-id") + if err != nil || !created || replay || item.Name != valid.Name || store.created.Plaintext != valid.Plaintext { + t.Fatalf("create result = %#v, %v, %v, %v", item, created, replay, err) + } +} + +func TestAllowedRoleMatrix(t *testing.T) { + if !Allowed(RoleAdmin, AccessRead) || !Allowed(RoleAdmin, AccessOperation) || !Allowed(RoleReadOnlyAdmin, AccessRead) || Allowed(RoleReadOnlyAdmin, AccessOperation) { + t.Fatal("role access matrix is incorrect") + } +} + +func TestRequestRegistryCancelsOnlyMatchingToken(t *testing.T) { + var registry requestRegistry + first, second := false, false + unregister := registry.register("token-1", func() { first = true }) + registry.register("token-2", func() { second = true }) + registry.cancelToken("token-1") + if !first || second { + t.Fatalf("cancellation state = %v/%v", first, second) + } + unregister() +} diff --git a/pkg/identity/identity.go b/pkg/identity/identity.go index 5af70b10a..0201f0e49 100644 --- a/pkg/identity/identity.go +++ b/pkg/identity/identity.go @@ -32,6 +32,8 @@ const ( ResourceSandbox ResourceKind = "sandbox" ResourceCache ResourceKind = "cache" ResourceWorkspace ResourceKind = "workspace" + ResourceAuthToken ResourceKind = "auth-token" + ResourceAudit ResourceKind = "operation-audit" ) func NewID(kind ResourceKind, parts ...string) string { diff --git a/pkg/storage/configstore/auth_store.go b/pkg/storage/configstore/auth_store.go new file mode 100644 index 000000000..cf0a7db93 --- /dev/null +++ b/pkg/storage/configstore/auth_store.go @@ -0,0 +1,490 @@ +package configstore + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + controlauth "agent-compose/pkg/auth" + "agent-compose/pkg/identity" +) + +type authStore struct{ db *sql.DB } + +func (s *authStore) ensureAuthSchema(ctx context.Context) error { + statements := []string{ + `CREATE TABLE IF NOT EXISTS auth_token ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL COLLATE NOCASE UNIQUE, + description TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + token_fingerprint TEXT NOT NULL, + origin TEXT NOT NULL DEFAULT 'issued', + created_by_token_id TEXT NOT NULL DEFAULT '', + client_request_id TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + revoked_by_token_id TEXT NOT NULL DEFAULT '', + revoked_at INTEGER NOT NULL DEFAULT 0 + );`, + `CREATE INDEX IF NOT EXISTS idx_auth_token_role_active ON auth_token(role, revoked_at);`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_token_create_request + ON auth_token(created_by_token_id, client_request_id) WHERE client_request_id != '';`, + `CREATE TABLE IF NOT EXISTS operation_audit ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + request_id TEXT NOT NULL, + token_id TEXT NOT NULL DEFAULT '', + token_name TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT '', + origin TEXT NOT NULL DEFAULT '', + action TEXT NOT NULL, + resource_type TEXT NOT NULL DEFAULT '', + resource_id TEXT NOT NULL DEFAULT '', + project_id TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL, + params_json TEXT NOT NULL DEFAULT '{}', + changes_json TEXT NOT NULL DEFAULT '{}', + error_code TEXT NOT NULL DEFAULT '', + error_message TEXT NOT NULL DEFAULT '', + started_at INTEGER NOT NULL, + completed_at INTEGER NOT NULL DEFAULT 0 + );`, + `CREATE INDEX IF NOT EXISTS idx_operation_audit_token_sequence ON operation_audit(token_id, sequence DESC);`, + `CREATE INDEX IF NOT EXISTS idx_operation_audit_action_sequence ON operation_audit(action, sequence DESC);`, + `CREATE INDEX IF NOT EXISTS idx_operation_audit_project_sequence ON operation_audit(project_id, sequence DESC);`, + } + for _, statement := range statements { + if _, err := s.db.ExecContext(ctx, statement); err != nil { + return fmt.Errorf("create control-plane auth schema: %w", err) + } + } + return nil +} + +func (s *authStore) ReconcileEnvironmentToken(ctx context.Context, plaintext string, now time.Time) (controlauth.Token, bool, error) { + id := identity.NewID(identity.ResourceAuthToken, controlauth.OriginEnvironment, controlauth.DefaultAdminName) + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return controlauth.Token{}, false, fmt.Errorf("begin environment token reconciliation: %w", err) + } + defer func() { _ = tx.Rollback() }() + + existing, found, err := findAuthToken(ctx, tx, id) + if err != nil { + return controlauth.Token{}, false, err + } + plaintext = strings.TrimSpace(plaintext) + if plaintext == "" { + if !found || !existing.Active() { + return existing, false, tx.Commit() + } + existing.RevokedAt = now + existing.RevokedByTokenID = "system" + if _, err := tx.ExecContext(ctx, `UPDATE auth_token SET revoked_by_token_id = ?, revoked_at = ? WHERE id = ?`, "system", now.UnixMilli(), id); err != nil { + return controlauth.Token{}, false, fmt.Errorf("revoke environment token: %w", err) + } + if err := insertAuditTx(ctx, tx, controlauth.Audit{ + ID: identity.NewRandomID(identity.ResourceAudit), RequestID: "startup", TokenName: "system", + Origin: controlauth.OriginSystem, Action: "auth.token.reconcile", Resource: controlauth.Resource{Type: "auth-token", ID: id}, + Status: controlauth.AuditStatusSucceeded, ParamsJSON: `{"source":"environment"}`, + ChangesJSON: `{"revoked":true}`, StartedAt: now, CompletedAt: now, + }); err != nil { + return controlauth.Token{}, false, err + } + return existing, true, tx.Commit() + } + + hash := controlauth.TokenHash(plaintext) + var conflictingID string + if err := tx.QueryRowContext(ctx, `SELECT id FROM auth_token WHERE token_hash = ? AND id != ?`, hash, id).Scan(&conflictingID); err == nil { + return controlauth.Token{}, false, fmt.Errorf("%w: environment token matches token %s", controlauth.ErrTokenConflict, conflictingID) + } else if !errors.Is(err, sql.ErrNoRows) { + return controlauth.Token{}, false, fmt.Errorf("check environment token hash: %w", err) + } + + fingerprint := controlauth.TokenFingerprint(plaintext) + changed := !found || existing.Role != controlauth.RoleAdmin || existing.Origin != controlauth.OriginEnvironment || existing.Fingerprint != fingerprint || !existing.Active() + if !changed { + return existing, false, tx.Commit() + } + if !found { + existing = controlauth.Token{ + ID: id, Name: controlauth.DefaultAdminName, Description: "Managed by AGENT_COMPOSE_AUTH_TOKEN", + Role: controlauth.RoleAdmin, Origin: controlauth.OriginEnvironment, Fingerprint: fingerprint, + CreatedByTokenID: "system", CreatedAt: now, + } + if _, err := tx.ExecContext(ctx, `INSERT INTO auth_token( + id, name, description, role, token_hash, token_fingerprint, origin, created_by_token_id, client_request_id, created_at, revoked_by_token_id, revoked_at + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, '', ?, '', 0)`, + existing.ID, existing.Name, existing.Description, existing.Role, hash, fingerprint, existing.Origin, existing.CreatedByTokenID, now.UnixMilli()); err != nil { + return controlauth.Token{}, false, fmt.Errorf("insert environment token: %w", err) + } + } else { + existing.Name = controlauth.DefaultAdminName + existing.Description = "Managed by AGENT_COMPOSE_AUTH_TOKEN" + existing.Role = controlauth.RoleAdmin + existing.Origin = controlauth.OriginEnvironment + existing.Fingerprint = fingerprint + existing.RevokedAt = time.Time{} + existing.RevokedByTokenID = "" + if _, err := tx.ExecContext(ctx, `UPDATE auth_token SET + name = ?, description = ?, role = ?, token_hash = ?, token_fingerprint = ?, origin = ?, revoked_by_token_id = '', revoked_at = 0 + WHERE id = ?`, existing.Name, existing.Description, existing.Role, hash, fingerprint, existing.Origin, id); err != nil { + return controlauth.Token{}, false, fmt.Errorf("update environment token: %w", err) + } + } + if err := insertAuditTx(ctx, tx, controlauth.Audit{ + ID: identity.NewRandomID(identity.ResourceAudit), RequestID: "startup", TokenName: "system", + Origin: controlauth.OriginSystem, Action: "auth.token.reconcile", Resource: controlauth.Resource{Type: "auth-token", ID: id}, + Status: controlauth.AuditStatusSucceeded, ParamsJSON: `{"source":"environment"}`, + ChangesJSON: `{"active":true,"role":"admin"}`, StartedAt: now, CompletedAt: now, + }); err != nil { + return controlauth.Token{}, false, err + } + return existing, true, tx.Commit() +} + +func (s *authStore) AuthInitialized(ctx context.Context) (bool, error) { + var initialized bool + if err := s.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM auth_token)`).Scan(&initialized); err != nil { + return false, fmt.Errorf("query auth initialization: %w", err) + } + return initialized, nil +} + +func (s *authStore) AuthenticateTokenHash(ctx context.Context, hash string) (controlauth.Token, error) { + row := s.db.QueryRowContext(ctx, authTokenSelect+` WHERE token_hash = ? AND revoked_at = 0`, strings.TrimSpace(hash)) + token, err := scanAuthToken(row.Scan) + if errors.Is(err, sql.ErrNoRows) { + return controlauth.Token{}, controlauth.ErrInvalidToken + } + if err != nil { + return controlauth.Token{}, fmt.Errorf("authenticate daemon token: %w", err) + } + return token, nil +} + +func (s *authStore) ListTokens(ctx context.Context, options controlauth.TokenListOptions) (controlauth.TokenListResult, error) { + limit := options.Limit + if limit <= 0 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + where := []string{"1 = 1"} + args := make([]any, 0, 3) + if !options.IncludeRevoked { + where = append(where, "revoked_at = 0") + } + if options.BeforeID != "" { + where = append(where, "id < ?") + args = append(args, options.BeforeID) + } + args = append(args, limit+1) + rows, err := s.db.QueryContext(ctx, authTokenSelect+` WHERE `+strings.Join(where, " AND ")+` ORDER BY id DESC LIMIT ?`, args...) + if err != nil { + return controlauth.TokenListResult{}, fmt.Errorf("list daemon tokens: %w", err) + } + defer func() { _ = rows.Close() }() + items := make([]controlauth.Token, 0, limit+1) + for rows.Next() { + item, scanErr := scanAuthToken(rows.Scan) + if scanErr != nil { + return controlauth.TokenListResult{}, fmt.Errorf("scan daemon token: %w", scanErr) + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return controlauth.TokenListResult{}, fmt.Errorf("iterate daemon tokens: %w", err) + } + result := controlauth.TokenListResult{Tokens: items} + if len(items) > limit { + result.NextCursor = items[limit-1].ID + result.Tokens = items[:limit] + } + return result, nil +} + +func (s *authStore) CreateToken(ctx context.Context, actor controlauth.Identity, input controlauth.CreateTokenInput, requestID, paramsJSON string, now time.Time) (controlauth.Token, bool, bool, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return controlauth.Token{}, false, false, fmt.Errorf("begin token create: %w", err) + } + defer func() { _ = tx.Rollback() }() + hash := controlauth.TokenHash(input.Plaintext) + row := tx.QueryRowContext(ctx, authTokenSelect+` WHERE created_by_token_id = ? AND client_request_id = ?`, actor.TokenID, input.ClientRequestID) + existing, findErr := scanAuthToken(row.Scan) + if findErr == nil { + var existingHash string + if err := tx.QueryRowContext(ctx, `SELECT token_hash FROM auth_token WHERE id = ?`, existing.ID).Scan(&existingHash); err != nil { + return controlauth.Token{}, false, false, fmt.Errorf("read idempotent token hash: %w", err) + } + if existing.Name != input.Name || existing.Description != input.Description || existing.Role != input.Role || existingHash != hash { + return controlauth.Token{}, false, false, controlauth.ErrIdempotencyConflict + } + return existing, false, true, tx.Commit() + } + if !errors.Is(findErr, sql.ErrNoRows) { + return controlauth.Token{}, false, false, fmt.Errorf("find idempotent token create: %w", findErr) + } + + item := controlauth.Token{ + ID: identity.NewRandomID(identity.ResourceAuthToken), Name: input.Name, Description: input.Description, + Role: input.Role, Origin: controlauth.OriginIssued, Fingerprint: controlauth.TokenFingerprint(input.Plaintext), + CreatedByTokenID: actor.TokenID, ClientRequestID: input.ClientRequestID, CreatedAt: now, + } + if _, err := tx.ExecContext(ctx, `INSERT INTO auth_token( + id, name, description, role, token_hash, token_fingerprint, origin, created_by_token_id, client_request_id, created_at, revoked_by_token_id, revoked_at + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 0)`, item.ID, item.Name, item.Description, item.Role, hash, item.Fingerprint, item.Origin, item.CreatedByTokenID, item.ClientRequestID, now.UnixMilli()); err != nil { + return controlauth.Token{}, false, false, fmt.Errorf("%w: insert daemon token: %v", controlauth.ErrTokenConflict, err) + } + changes, _ := json.Marshal(map[string]any{"created": true, "token_id": item.ID, "name": item.Name, "role": item.Role}) + if err := insertAuditTx(ctx, tx, controlauth.Audit{ + ID: identity.NewRandomID(identity.ResourceAudit), RequestID: requestID, + TokenID: actor.TokenID, TokenName: actor.TokenName, Role: actor.Role, Origin: actor.Origin, + Action: "auth.token.create", Resource: controlauth.Resource{Type: "auth-token", ID: item.ID}, + Status: controlauth.AuditStatusSucceeded, ParamsJSON: paramsJSON, ChangesJSON: string(changes), StartedAt: now, CompletedAt: now, + }); err != nil { + return controlauth.Token{}, false, false, err + } + if err := tx.Commit(); err != nil { + return controlauth.Token{}, false, false, fmt.Errorf("commit token create: %w", err) + } + return item, true, false, nil +} + +func (s *authStore) RevokeToken(ctx context.Context, actor controlauth.Identity, ref, requestID, paramsJSON string, now time.Time) (controlauth.Token, bool, bool, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return controlauth.Token{}, false, false, fmt.Errorf("begin token revoke: %w", err) + } + defer func() { _ = tx.Rollback() }() + item, found, err := findAuthToken(ctx, tx, ref) + if err != nil { + return controlauth.Token{}, false, false, err + } + if !found { + return controlauth.Token{}, false, false, controlauth.ErrTokenNotFound + } + if item.ID == actor.TokenID { + return controlauth.Token{}, false, false, controlauth.ErrTokenSelfRevoke + } + if item.Origin == controlauth.OriginEnvironment { + return controlauth.Token{}, false, false, controlauth.ErrEnvironmentToken + } + if !item.Active() { + return item, false, true, tx.Commit() + } + if item.Role == controlauth.RoleAdmin && !actor.IsLocal() { + var activeAdmins int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(1) FROM auth_token WHERE role = ? AND revoked_at = 0`, controlauth.RoleAdmin).Scan(&activeAdmins); err != nil { + return controlauth.Token{}, false, false, fmt.Errorf("count active admin tokens: %w", err) + } + if activeAdmins <= 1 { + return controlauth.Token{}, false, false, controlauth.ErrLastAdminToken + } + } + item.RevokedAt = now + item.RevokedByTokenID = actor.TokenID + if _, err := tx.ExecContext(ctx, `UPDATE auth_token SET revoked_by_token_id = ?, revoked_at = ? WHERE id = ? AND revoked_at = 0`, actor.TokenID, now.UnixMilli(), item.ID); err != nil { + return controlauth.Token{}, false, false, fmt.Errorf("revoke daemon token: %w", err) + } + changes, _ := json.Marshal(map[string]any{"revoked": true, "token_id": item.ID, "name": item.Name}) + if err := insertAuditTx(ctx, tx, controlauth.Audit{ + ID: identity.NewRandomID(identity.ResourceAudit), RequestID: requestID, + TokenID: actor.TokenID, TokenName: actor.TokenName, Role: actor.Role, Origin: actor.Origin, + Action: "auth.token.revoke", Resource: controlauth.Resource{Type: "auth-token", ID: item.ID}, + Status: controlauth.AuditStatusSucceeded, ParamsJSON: paramsJSON, ChangesJSON: string(changes), StartedAt: now, CompletedAt: now, + }); err != nil { + return controlauth.Token{}, false, false, err + } + if err := tx.Commit(); err != nil { + return controlauth.Token{}, false, false, fmt.Errorf("commit token revoke: %w", err) + } + return item, true, false, nil +} + +func (s *authStore) BeginAudit(ctx context.Context, audit controlauth.Audit) error { + if _, err := s.db.ExecContext(ctx, auditInsert, auditValues(audit)...); err != nil { + return fmt.Errorf("begin operation audit: %w", err) + } + return nil +} + +func (s *authStore) FinishAudit(ctx context.Context, audit controlauth.Audit) error { + result, err := s.db.ExecContext(ctx, `UPDATE operation_audit SET resource_type = ?, resource_id = ?, project_id = ?, status = ?, params_json = ?, changes_json = ?, error_code = ?, error_message = ?, completed_at = ? WHERE id = ?`, + audit.Resource.Type, audit.Resource.ID, audit.Resource.ProjectID, audit.Status, audit.ParamsJSON, audit.ChangesJSON, audit.ErrorCode, audit.ErrorMessage, unixMillis(audit.CompletedAt), audit.ID) + if err != nil { + return fmt.Errorf("finish operation audit: %w", err) + } + if affected, _ := result.RowsAffected(); affected != 1 { + return fmt.Errorf("finish operation audit %s: record not found", audit.ID) + } + return nil +} + +func (s *authStore) ListAudits(ctx context.Context, filter controlauth.AuditFilter) (controlauth.AuditListResult, error) { + limit := filter.Limit + if limit <= 0 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + where := []string{"1 = 1"} + args := make([]any, 0, 10) + if filter.Token != "" { + item, found, err := findAuthToken(ctx, s.db, filter.Token) + if err != nil { + return controlauth.AuditListResult{}, err + } + if !found { + return controlauth.AuditListResult{}, controlauth.ErrTokenNotFound + } + where = append(where, "token_id = ?") + args = append(args, item.ID) + } + appendTextFilter := func(column, value string) { + if strings.TrimSpace(value) != "" { + where = append(where, column+" = ?") + args = append(args, strings.TrimSpace(value)) + } + } + appendTextFilter("action", filter.Action) + appendTextFilter("resource_type", filter.ResourceType) + appendTextFilter("resource_id", filter.ResourceID) + appendTextFilter("project_id", filter.ProjectID) + appendTextFilter("status", string(filter.Status)) + if !filter.StartedAfter.IsZero() { + where = append(where, "started_at >= ?") + args = append(args, filter.StartedAfter.UnixMilli()) + } + if !filter.StartedBefore.IsZero() { + where = append(where, "started_at <= ?") + args = append(args, filter.StartedBefore.UnixMilli()) + } + if filter.BeforeSequence > 0 { + where = append(where, "sequence < ?") + args = append(args, filter.BeforeSequence) + } + args = append(args, limit+1) + rows, err := s.db.QueryContext(ctx, auditSelect+` WHERE `+strings.Join(where, " AND ")+` ORDER BY sequence DESC LIMIT ?`, args...) + if err != nil { + return controlauth.AuditListResult{}, fmt.Errorf("list operation audits: %w", err) + } + defer func() { _ = rows.Close() }() + items := make([]controlauth.Audit, 0, limit+1) + for rows.Next() { + item, scanErr := scanAudit(rows.Scan) + if scanErr != nil { + return controlauth.AuditListResult{}, fmt.Errorf("scan operation audit: %w", scanErr) + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return controlauth.AuditListResult{}, fmt.Errorf("iterate operation audits: %w", err) + } + result := controlauth.AuditListResult{Audits: items} + if len(items) > limit { + result.NextCursor = strconv.FormatUint(items[limit-1].Sequence, 10) + result.Audits = items[:limit] + } + return result, nil +} + +const authTokenSelect = `SELECT id, name, description, role, token_fingerprint, origin, created_by_token_id, client_request_id, created_at, revoked_by_token_id, revoked_at FROM auth_token` + +func findAuthToken(ctx context.Context, query interface { + QueryRowContext(context.Context, string, ...any) *sql.Row +}, ref string) (controlauth.Token, bool, error) { + ref = strings.TrimSpace(ref) + row := query.QueryRowContext(ctx, authTokenSelect+` WHERE id = ? OR name = ? COLLATE NOCASE ORDER BY CASE WHEN id = ? THEN 0 ELSE 1 END LIMIT 1`, ref, ref, ref) + item, err := scanAuthToken(row.Scan) + if errors.Is(err, sql.ErrNoRows) { + return controlauth.Token{}, false, nil + } + if err != nil { + return controlauth.Token{}, false, fmt.Errorf("find daemon token %s: %w", ref, err) + } + return item, true, nil +} + +func scanAuthToken(scan func(...any) error) (controlauth.Token, error) { + var item controlauth.Token + var role string + var createdAt, revokedAt int64 + err := scan(&item.ID, &item.Name, &item.Description, &role, &item.Fingerprint, &item.Origin, &item.CreatedByTokenID, &item.ClientRequestID, &createdAt, &item.RevokedByTokenID, &revokedAt) + if err != nil { + return controlauth.Token{}, err + } + item.Role = controlauth.Role(role) + item.CreatedAt = time.UnixMilli(createdAt).UTC() + if revokedAt > 0 { + item.RevokedAt = time.UnixMilli(revokedAt).UTC() + } + return item, nil +} + +const auditInsert = `INSERT INTO operation_audit( + id, request_id, token_id, token_name, role, origin, action, resource_type, resource_id, project_id, + status, params_json, changes_json, error_code, error_message, started_at, completed_at +) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + +func insertAuditTx(ctx context.Context, tx *sql.Tx, audit controlauth.Audit) error { + if _, err := tx.ExecContext(ctx, auditInsert, auditValues(audit)...); err != nil { + return fmt.Errorf("insert operation audit: %w", err) + } + return nil +} + +func auditValues(audit controlauth.Audit) []any { + return []any{ + audit.ID, audit.RequestID, audit.TokenID, audit.TokenName, audit.Role, audit.Origin, audit.Action, + audit.Resource.Type, audit.Resource.ID, audit.Resource.ProjectID, audit.Status, + defaultJSON(audit.ParamsJSON), defaultJSON(audit.ChangesJSON), audit.ErrorCode, audit.ErrorMessage, + unixMillis(audit.StartedAt), unixMillis(audit.CompletedAt), + } +} + +const auditSelect = `SELECT sequence, id, request_id, token_id, token_name, role, origin, action, resource_type, resource_id, project_id, status, params_json, changes_json, error_code, error_message, started_at, completed_at FROM operation_audit` + +func scanAudit(scan func(...any) error) (controlauth.Audit, error) { + var item controlauth.Audit + var role, status string + var startedAt, completedAt int64 + err := scan(&item.Sequence, &item.ID, &item.RequestID, &item.TokenID, &item.TokenName, &role, &item.Origin, &item.Action, + &item.Resource.Type, &item.Resource.ID, &item.Resource.ProjectID, &status, &item.ParamsJSON, &item.ChangesJSON, + &item.ErrorCode, &item.ErrorMessage, &startedAt, &completedAt) + if err != nil { + return controlauth.Audit{}, err + } + item.Role = controlauth.Role(role) + item.Status = controlauth.AuditStatus(status) + item.StartedAt = time.UnixMilli(startedAt).UTC() + if completedAt > 0 { + item.CompletedAt = time.UnixMilli(completedAt).UTC() + } + return item, nil +} + +func defaultJSON(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "{}" + } + return value +} + +func unixMillis(value time.Time) int64 { + if value.IsZero() { + return 0 + } + return value.UTC().UnixMilli() +} diff --git a/pkg/storage/configstore/auth_store_test.go b/pkg/storage/configstore/auth_store_test.go new file mode 100644 index 000000000..9f1f9a1b9 --- /dev/null +++ b/pkg/storage/configstore/auth_store_test.go @@ -0,0 +1,89 @@ +package configstore + +import ( + "context" + "database/sql" + "errors" + "strings" + "testing" + "time" + + controlauth "agent-compose/pkg/auth" +) + +func newAuthTestStore(t *testing.T) *ConfigStore { + t.Helper() + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + db.SetMaxOpenConns(1) + t.Cleanup(func() { _ = db.Close() }) + store := FromDB(db) + if err := store.ensureAuthSchema(context.Background()); err != nil { + t.Fatal(err) + } + return store +} + +func TestAuthStoreEnvironmentTokenRotationAndNoPlaintext(t *testing.T) { + ctx := context.Background() + store := newAuthTestStore(t) + now := time.Unix(1_800_000_000, 0).UTC() + item, changed, err := store.ReconcileEnvironmentToken(ctx, "bootstrap-secret", now) + if err != nil || !changed || item.Name != controlauth.DefaultAdminName || item.Role != controlauth.RoleAdmin { + t.Fatalf("initial reconcile = %#v, %v, %v", item, changed, err) + } + if _, _, err := store.ReconcileEnvironmentToken(ctx, "rotated-secret", now.Add(time.Minute)); err != nil { + t.Fatal(err) + } + if _, err := store.AuthenticateTokenHash(ctx, controlauth.TokenHash("bootstrap-secret")); !errors.Is(err, controlauth.ErrInvalidToken) { + t.Fatalf("old token auth error = %v", err) + } + if _, err := store.AuthenticateTokenHash(ctx, controlauth.TokenHash("rotated-secret")); err != nil { + t.Fatalf("rotated token auth error = %v", err) + } + var storedHash string + if err := store.DB().QueryRow(`SELECT token_hash FROM auth_token WHERE id = ?`, item.ID).Scan(&storedHash); err != nil { + t.Fatal(err) + } + if strings.Contains(storedHash, "secret") || storedHash == "rotated-secret" { + t.Fatalf("database stored plaintext-like value %q", storedHash) + } +} + +func TestAuthStoreCreateIdempotencyAndRevocationRules(t *testing.T) { + ctx := context.Background() + store := newAuthTestStore(t) + now := time.Unix(1_800_000_000, 0).UTC() + environment, _, err := store.ReconcileEnvironmentToken(ctx, "bootstrap", now) + if err != nil { + t.Fatal(err) + } + actor := controlauth.Identity{TokenID: environment.ID, TokenName: environment.Name, Role: controlauth.RoleAdmin, Origin: controlauth.OriginEnvironment} + input := controlauth.CreateTokenInput{Name: "reader", Role: controlauth.RoleReadOnlyAdmin, Plaintext: "ac_0123456789012345678901234567890123456789012", ClientRequestID: "create-1"} + created, ok, replay, err := store.CreateToken(ctx, actor, input, "request-1", `{}`, now) + if err != nil || !ok || replay { + t.Fatalf("create = %#v, %v, %v, %v", created, ok, replay, err) + } + again, ok, replay, err := store.CreateToken(ctx, actor, input, "request-2", `{}`, now) + if err != nil || ok || !replay || again.ID != created.ID { + t.Fatalf("replay = %#v, %v, %v, %v", again, ok, replay, err) + } + conflict := input + conflict.Name = "different" + if _, _, _, err := store.CreateToken(ctx, actor, conflict, "request-3", `{}`, now); !errors.Is(err, controlauth.ErrIdempotencyConflict) { + t.Fatalf("idempotency conflict error = %v", err) + } + if _, _, _, err := store.RevokeToken(ctx, actor, environment.ID, "request-4", `{}`, now); !errors.Is(err, controlauth.ErrTokenSelfRevoke) { + t.Fatalf("self revoke error = %v", err) + } + local := controlauth.Identity{TokenName: "local-admin", Role: controlauth.RoleAdmin, Origin: controlauth.OriginLocal} + if _, revoked, _, err := store.RevokeToken(ctx, local, created.ID, "request-5", `{}`, now); err != nil || !revoked { + t.Fatalf("local revoke = %v, %v", revoked, err) + } + audits, err := store.ListAudits(ctx, controlauth.AuditFilter{Action: "auth.token.create", Limit: 10}) + if err != nil || len(audits.Audits) != 1 { + t.Fatalf("create audits = %#v, %v", audits, err) + } +} diff --git a/pkg/storage/configstore/core_store.go b/pkg/storage/configstore/core_store.go index 69ea09c8a..bddb40917 100644 --- a/pkg/storage/configstore/core_store.go +++ b/pkg/storage/configstore/core_store.go @@ -58,6 +58,7 @@ type ConfigStore struct { *llmStore *capabilityGatewayStore *volumeStore + *authStore } func NewConfigStore(di do.Injector) (*ConfigStore, error) { @@ -90,6 +91,7 @@ func FromDB(db *sql.DB) *ConfigStore { llmStore: &llmStore{db: db}, capabilityGatewayStore: &capabilityGatewayStore{db: db}, volumeStore: &volumeStore{db: db}, + authStore: &authStore{db: db}, } } @@ -150,6 +152,9 @@ func (s *ConfigStore) initSchema(ctx context.Context) error { if err := s.ensureEventSchema(ctx); err != nil { return err } + if err := s.ensureAuthSchema(ctx); err != nil { + return err + } if err := s.copyLegacyEventSessionLinks(ctx); err != nil { return err } diff --git a/proto/agentcompose/v2/agentcompose.pb.go b/proto/agentcompose/v2/agentcompose.pb.go index e4c213a27..ebdd288d4 100644 --- a/proto/agentcompose/v2/agentcompose.pb.go +++ b/proto/agentcompose/v2/agentcompose.pb.go @@ -16432,6 +16432,1066 @@ func (x *GenerateLLMResponse) GetJson() string { return "" } +type AuthToken struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` + Role string `protobuf:"bytes,4,opt,name=role,proto3" json:"role,omitempty"` + Origin string `protobuf:"bytes,5,opt,name=origin,proto3" json:"origin,omitempty"` + CreatedByTokenId string `protobuf:"bytes,7,opt,name=created_by_token_id,json=createdByTokenId,proto3" json:"created_by_token_id,omitempty"` + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + RevokedByTokenId string `protobuf:"bytes,9,opt,name=revoked_by_token_id,json=revokedByTokenId,proto3" json:"revoked_by_token_id,omitempty"` + RevokedAt *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=revoked_at,json=revokedAt,proto3" json:"revoked_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AuthToken) Reset() { + *x = AuthToken{} + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[214] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AuthToken) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AuthToken) ProtoMessage() {} + +func (x *AuthToken) ProtoReflect() protoreflect.Message { + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[214] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AuthToken.ProtoReflect.Descriptor instead. +func (*AuthToken) Descriptor() ([]byte, []int) { + return file_agentcompose_v2_agentcompose_proto_rawDescGZIP(), []int{214} +} + +func (x *AuthToken) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *AuthToken) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *AuthToken) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *AuthToken) GetRole() string { + if x != nil { + return x.Role + } + return "" +} + +func (x *AuthToken) GetOrigin() string { + if x != nil { + return x.Origin + } + return "" +} + +func (x *AuthToken) GetCreatedByTokenId() string { + if x != nil { + return x.CreatedByTokenId + } + return "" +} + +func (x *AuthToken) GetCreatedAt() *timestamppb.Timestamp { + if x != nil { + return x.CreatedAt + } + return nil +} + +func (x *AuthToken) GetRevokedByTokenId() string { + if x != nil { + return x.RevokedByTokenId + } + return "" +} + +func (x *AuthToken) GetRevokedAt() *timestamppb.Timestamp { + if x != nil { + return x.RevokedAt + } + return nil +} + +type WhoAmIRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WhoAmIRequest) Reset() { + *x = WhoAmIRequest{} + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[215] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WhoAmIRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WhoAmIRequest) ProtoMessage() {} + +func (x *WhoAmIRequest) ProtoReflect() protoreflect.Message { + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[215] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WhoAmIRequest.ProtoReflect.Descriptor instead. +func (*WhoAmIRequest) Descriptor() ([]byte, []int) { + return file_agentcompose_v2_agentcompose_proto_rawDescGZIP(), []int{215} +} + +type WhoAmIResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Token *AuthToken `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` + Role string `protobuf:"bytes,2,opt,name=role,proto3" json:"role,omitempty"` + Origin string `protobuf:"bytes,3,opt,name=origin,proto3" json:"origin,omitempty"` + AuthenticationInitialized bool `protobuf:"varint,4,opt,name=authentication_initialized,json=authenticationInitialized,proto3" json:"authentication_initialized,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WhoAmIResponse) Reset() { + *x = WhoAmIResponse{} + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[216] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WhoAmIResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WhoAmIResponse) ProtoMessage() {} + +func (x *WhoAmIResponse) ProtoReflect() protoreflect.Message { + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[216] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WhoAmIResponse.ProtoReflect.Descriptor instead. +func (*WhoAmIResponse) Descriptor() ([]byte, []int) { + return file_agentcompose_v2_agentcompose_proto_rawDescGZIP(), []int{216} +} + +func (x *WhoAmIResponse) GetToken() *AuthToken { + if x != nil { + return x.Token + } + return nil +} + +func (x *WhoAmIResponse) GetRole() string { + if x != nil { + return x.Role + } + return "" +} + +func (x *WhoAmIResponse) GetOrigin() string { + if x != nil { + return x.Origin + } + return "" +} + +func (x *WhoAmIResponse) GetAuthenticationInitialized() bool { + if x != nil { + return x.AuthenticationInitialized + } + return false +} + +type AuthRole struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` + ReadOnly bool `protobuf:"varint,3,opt,name=read_only,json=readOnly,proto3" json:"read_only,omitempty"` + Builtin bool `protobuf:"varint,4,opt,name=builtin,proto3" json:"builtin,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AuthRole) Reset() { + *x = AuthRole{} + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[217] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AuthRole) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AuthRole) ProtoMessage() {} + +func (x *AuthRole) ProtoReflect() protoreflect.Message { + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[217] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AuthRole.ProtoReflect.Descriptor instead. +func (*AuthRole) Descriptor() ([]byte, []int) { + return file_agentcompose_v2_agentcompose_proto_rawDescGZIP(), []int{217} +} + +func (x *AuthRole) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *AuthRole) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *AuthRole) GetReadOnly() bool { + if x != nil { + return x.ReadOnly + } + return false +} + +func (x *AuthRole) GetBuiltin() bool { + if x != nil { + return x.Builtin + } + return false +} + +type ListRolesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListRolesRequest) Reset() { + *x = ListRolesRequest{} + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[218] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListRolesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListRolesRequest) ProtoMessage() {} + +func (x *ListRolesRequest) ProtoReflect() protoreflect.Message { + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[218] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListRolesRequest.ProtoReflect.Descriptor instead. +func (*ListRolesRequest) Descriptor() ([]byte, []int) { + return file_agentcompose_v2_agentcompose_proto_rawDescGZIP(), []int{218} +} + +type ListRolesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Roles []*AuthRole `protobuf:"bytes,1,rep,name=roles,proto3" json:"roles,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListRolesResponse) Reset() { + *x = ListRolesResponse{} + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[219] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListRolesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListRolesResponse) ProtoMessage() {} + +func (x *ListRolesResponse) ProtoReflect() protoreflect.Message { + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[219] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListRolesResponse.ProtoReflect.Descriptor instead. +func (*ListRolesResponse) Descriptor() ([]byte, []int) { + return file_agentcompose_v2_agentcompose_proto_rawDescGZIP(), []int{219} +} + +func (x *ListRolesResponse) GetRoles() []*AuthRole { + if x != nil { + return x.Roles + } + return nil +} + +type ListTokensRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + IncludeRevoked bool `protobuf:"varint,1,opt,name=include_revoked,json=includeRevoked,proto3" json:"include_revoked,omitempty"` + Limit uint32 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"` + Cursor string `protobuf:"bytes,3,opt,name=cursor,proto3" json:"cursor,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListTokensRequest) Reset() { + *x = ListTokensRequest{} + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[220] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListTokensRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListTokensRequest) ProtoMessage() {} + +func (x *ListTokensRequest) ProtoReflect() protoreflect.Message { + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[220] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListTokensRequest.ProtoReflect.Descriptor instead. +func (*ListTokensRequest) Descriptor() ([]byte, []int) { + return file_agentcompose_v2_agentcompose_proto_rawDescGZIP(), []int{220} +} + +func (x *ListTokensRequest) GetIncludeRevoked() bool { + if x != nil { + return x.IncludeRevoked + } + return false +} + +func (x *ListTokensRequest) GetLimit() uint32 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *ListTokensRequest) GetCursor() string { + if x != nil { + return x.Cursor + } + return "" +} + +type ListTokensResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Tokens []*AuthToken `protobuf:"bytes,1,rep,name=tokens,proto3" json:"tokens,omitempty"` + NextCursor string `protobuf:"bytes,2,opt,name=next_cursor,json=nextCursor,proto3" json:"next_cursor,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListTokensResponse) Reset() { + *x = ListTokensResponse{} + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[221] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListTokensResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListTokensResponse) ProtoMessage() {} + +func (x *ListTokensResponse) ProtoReflect() protoreflect.Message { + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[221] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListTokensResponse.ProtoReflect.Descriptor instead. +func (*ListTokensResponse) Descriptor() ([]byte, []int) { + return file_agentcompose_v2_agentcompose_proto_rawDescGZIP(), []int{221} +} + +func (x *ListTokensResponse) GetTokens() []*AuthToken { + if x != nil { + return x.Tokens + } + return nil +} + +func (x *ListTokensResponse) GetNextCursor() string { + if x != nil { + return x.NextCursor + } + return "" +} + +type CreateTokenRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` + Role string `protobuf:"bytes,3,opt,name=role,proto3" json:"role,omitempty"` + Token string `protobuf:"bytes,4,opt,name=token,proto3" json:"token,omitempty"` + ClientRequestId string `protobuf:"bytes,5,opt,name=client_request_id,json=clientRequestId,proto3" json:"client_request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateTokenRequest) Reset() { + *x = CreateTokenRequest{} + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[222] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateTokenRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateTokenRequest) ProtoMessage() {} + +func (x *CreateTokenRequest) ProtoReflect() protoreflect.Message { + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[222] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateTokenRequest.ProtoReflect.Descriptor instead. +func (*CreateTokenRequest) Descriptor() ([]byte, []int) { + return file_agentcompose_v2_agentcompose_proto_rawDescGZIP(), []int{222} +} + +func (x *CreateTokenRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *CreateTokenRequest) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *CreateTokenRequest) GetRole() string { + if x != nil { + return x.Role + } + return "" +} + +func (x *CreateTokenRequest) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *CreateTokenRequest) GetClientRequestId() string { + if x != nil { + return x.ClientRequestId + } + return "" +} + +type CreateTokenResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Item *AuthToken `protobuf:"bytes,1,opt,name=item,proto3" json:"item,omitempty"` + Created bool `protobuf:"varint,2,opt,name=created,proto3" json:"created,omitempty"` + IdempotentReplay bool `protobuf:"varint,3,opt,name=idempotent_replay,json=idempotentReplay,proto3" json:"idempotent_replay,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateTokenResponse) Reset() { + *x = CreateTokenResponse{} + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[223] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateTokenResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateTokenResponse) ProtoMessage() {} + +func (x *CreateTokenResponse) ProtoReflect() protoreflect.Message { + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[223] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateTokenResponse.ProtoReflect.Descriptor instead. +func (*CreateTokenResponse) Descriptor() ([]byte, []int) { + return file_agentcompose_v2_agentcompose_proto_rawDescGZIP(), []int{223} +} + +func (x *CreateTokenResponse) GetItem() *AuthToken { + if x != nil { + return x.Item + } + return nil +} + +func (x *CreateTokenResponse) GetCreated() bool { + if x != nil { + return x.Created + } + return false +} + +func (x *CreateTokenResponse) GetIdempotentReplay() bool { + if x != nil { + return x.IdempotentReplay + } + return false +} + +type RevokeTokenRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RevokeTokenRequest) Reset() { + *x = RevokeTokenRequest{} + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[224] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RevokeTokenRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RevokeTokenRequest) ProtoMessage() {} + +func (x *RevokeTokenRequest) ProtoReflect() protoreflect.Message { + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[224] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RevokeTokenRequest.ProtoReflect.Descriptor instead. +func (*RevokeTokenRequest) Descriptor() ([]byte, []int) { + return file_agentcompose_v2_agentcompose_proto_rawDescGZIP(), []int{224} +} + +func (x *RevokeTokenRequest) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +type RevokeTokenResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Item *AuthToken `protobuf:"bytes,1,opt,name=item,proto3" json:"item,omitempty"` + Revoked bool `protobuf:"varint,2,opt,name=revoked,proto3" json:"revoked,omitempty"` + AlreadyRevoked bool `protobuf:"varint,3,opt,name=already_revoked,json=alreadyRevoked,proto3" json:"already_revoked,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RevokeTokenResponse) Reset() { + *x = RevokeTokenResponse{} + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[225] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RevokeTokenResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RevokeTokenResponse) ProtoMessage() {} + +func (x *RevokeTokenResponse) ProtoReflect() protoreflect.Message { + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[225] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RevokeTokenResponse.ProtoReflect.Descriptor instead. +func (*RevokeTokenResponse) Descriptor() ([]byte, []int) { + return file_agentcompose_v2_agentcompose_proto_rawDescGZIP(), []int{225} +} + +func (x *RevokeTokenResponse) GetItem() *AuthToken { + if x != nil { + return x.Item + } + return nil +} + +func (x *RevokeTokenResponse) GetRevoked() bool { + if x != nil { + return x.Revoked + } + return false +} + +func (x *RevokeTokenResponse) GetAlreadyRevoked() bool { + if x != nil { + return x.AlreadyRevoked + } + return false +} + +type OperationAudit struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Sequence uint64 `protobuf:"varint,2,opt,name=sequence,proto3" json:"sequence,omitempty"` + RequestId string `protobuf:"bytes,3,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + TokenId string `protobuf:"bytes,4,opt,name=token_id,json=tokenId,proto3" json:"token_id,omitempty"` + TokenName string `protobuf:"bytes,5,opt,name=token_name,json=tokenName,proto3" json:"token_name,omitempty"` + Role string `protobuf:"bytes,6,opt,name=role,proto3" json:"role,omitempty"` + Origin string `protobuf:"bytes,7,opt,name=origin,proto3" json:"origin,omitempty"` + Action string `protobuf:"bytes,8,opt,name=action,proto3" json:"action,omitempty"` + ResourceType string `protobuf:"bytes,9,opt,name=resource_type,json=resourceType,proto3" json:"resource_type,omitempty"` + ResourceId string `protobuf:"bytes,10,opt,name=resource_id,json=resourceId,proto3" json:"resource_id,omitempty"` + ProjectId string `protobuf:"bytes,11,opt,name=project_id,json=projectId,proto3" json:"project_id,omitempty"` + Status string `protobuf:"bytes,12,opt,name=status,proto3" json:"status,omitempty"` + ParamsJson string `protobuf:"bytes,13,opt,name=params_json,json=paramsJson,proto3" json:"params_json,omitempty"` + ChangesJson string `protobuf:"bytes,14,opt,name=changes_json,json=changesJson,proto3" json:"changes_json,omitempty"` + ErrorCode string `protobuf:"bytes,15,opt,name=error_code,json=errorCode,proto3" json:"error_code,omitempty"` + ErrorMessage string `protobuf:"bytes,16,opt,name=error_message,json=errorMessage,proto3" json:"error_message,omitempty"` + StartedAt *timestamppb.Timestamp `protobuf:"bytes,17,opt,name=started_at,json=startedAt,proto3" json:"started_at,omitempty"` + CompletedAt *timestamppb.Timestamp `protobuf:"bytes,18,opt,name=completed_at,json=completedAt,proto3" json:"completed_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OperationAudit) Reset() { + *x = OperationAudit{} + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[226] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OperationAudit) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OperationAudit) ProtoMessage() {} + +func (x *OperationAudit) ProtoReflect() protoreflect.Message { + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[226] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OperationAudit.ProtoReflect.Descriptor instead. +func (*OperationAudit) Descriptor() ([]byte, []int) { + return file_agentcompose_v2_agentcompose_proto_rawDescGZIP(), []int{226} +} + +func (x *OperationAudit) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *OperationAudit) GetSequence() uint64 { + if x != nil { + return x.Sequence + } + return 0 +} + +func (x *OperationAudit) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *OperationAudit) GetTokenId() string { + if x != nil { + return x.TokenId + } + return "" +} + +func (x *OperationAudit) GetTokenName() string { + if x != nil { + return x.TokenName + } + return "" +} + +func (x *OperationAudit) GetRole() string { + if x != nil { + return x.Role + } + return "" +} + +func (x *OperationAudit) GetOrigin() string { + if x != nil { + return x.Origin + } + return "" +} + +func (x *OperationAudit) GetAction() string { + if x != nil { + return x.Action + } + return "" +} + +func (x *OperationAudit) GetResourceType() string { + if x != nil { + return x.ResourceType + } + return "" +} + +func (x *OperationAudit) GetResourceId() string { + if x != nil { + return x.ResourceId + } + return "" +} + +func (x *OperationAudit) GetProjectId() string { + if x != nil { + return x.ProjectId + } + return "" +} + +func (x *OperationAudit) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *OperationAudit) GetParamsJson() string { + if x != nil { + return x.ParamsJson + } + return "" +} + +func (x *OperationAudit) GetChangesJson() string { + if x != nil { + return x.ChangesJson + } + return "" +} + +func (x *OperationAudit) GetErrorCode() string { + if x != nil { + return x.ErrorCode + } + return "" +} + +func (x *OperationAudit) GetErrorMessage() string { + if x != nil { + return x.ErrorMessage + } + return "" +} + +func (x *OperationAudit) GetStartedAt() *timestamppb.Timestamp { + if x != nil { + return x.StartedAt + } + return nil +} + +func (x *OperationAudit) GetCompletedAt() *timestamppb.Timestamp { + if x != nil { + return x.CompletedAt + } + return nil +} + +type ListOperationAuditsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` + Action string `protobuf:"bytes,2,opt,name=action,proto3" json:"action,omitempty"` + ResourceType string `protobuf:"bytes,3,opt,name=resource_type,json=resourceType,proto3" json:"resource_type,omitempty"` + ResourceId string `protobuf:"bytes,4,opt,name=resource_id,json=resourceId,proto3" json:"resource_id,omitempty"` + ProjectId string `protobuf:"bytes,5,opt,name=project_id,json=projectId,proto3" json:"project_id,omitempty"` + Status string `protobuf:"bytes,6,opt,name=status,proto3" json:"status,omitempty"` + StartedAfter *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=started_after,json=startedAfter,proto3" json:"started_after,omitempty"` + StartedBefore *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=started_before,json=startedBefore,proto3" json:"started_before,omitempty"` + Limit uint32 `protobuf:"varint,9,opt,name=limit,proto3" json:"limit,omitempty"` + Cursor string `protobuf:"bytes,10,opt,name=cursor,proto3" json:"cursor,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListOperationAuditsRequest) Reset() { + *x = ListOperationAuditsRequest{} + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[227] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListOperationAuditsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListOperationAuditsRequest) ProtoMessage() {} + +func (x *ListOperationAuditsRequest) ProtoReflect() protoreflect.Message { + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[227] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListOperationAuditsRequest.ProtoReflect.Descriptor instead. +func (*ListOperationAuditsRequest) Descriptor() ([]byte, []int) { + return file_agentcompose_v2_agentcompose_proto_rawDescGZIP(), []int{227} +} + +func (x *ListOperationAuditsRequest) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *ListOperationAuditsRequest) GetAction() string { + if x != nil { + return x.Action + } + return "" +} + +func (x *ListOperationAuditsRequest) GetResourceType() string { + if x != nil { + return x.ResourceType + } + return "" +} + +func (x *ListOperationAuditsRequest) GetResourceId() string { + if x != nil { + return x.ResourceId + } + return "" +} + +func (x *ListOperationAuditsRequest) GetProjectId() string { + if x != nil { + return x.ProjectId + } + return "" +} + +func (x *ListOperationAuditsRequest) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *ListOperationAuditsRequest) GetStartedAfter() *timestamppb.Timestamp { + if x != nil { + return x.StartedAfter + } + return nil +} + +func (x *ListOperationAuditsRequest) GetStartedBefore() *timestamppb.Timestamp { + if x != nil { + return x.StartedBefore + } + return nil +} + +func (x *ListOperationAuditsRequest) GetLimit() uint32 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *ListOperationAuditsRequest) GetCursor() string { + if x != nil { + return x.Cursor + } + return "" +} + +type ListOperationAuditsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Audits []*OperationAudit `protobuf:"bytes,1,rep,name=audits,proto3" json:"audits,omitempty"` + NextCursor string `protobuf:"bytes,2,opt,name=next_cursor,json=nextCursor,proto3" json:"next_cursor,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListOperationAuditsResponse) Reset() { + *x = ListOperationAuditsResponse{} + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[228] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListOperationAuditsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListOperationAuditsResponse) ProtoMessage() {} + +func (x *ListOperationAuditsResponse) ProtoReflect() protoreflect.Message { + mi := &file_agentcompose_v2_agentcompose_proto_msgTypes[228] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListOperationAuditsResponse.ProtoReflect.Descriptor instead. +func (*ListOperationAuditsResponse) Descriptor() ([]byte, []int) { + return file_agentcompose_v2_agentcompose_proto_rawDescGZIP(), []int{228} +} + +func (x *ListOperationAuditsResponse) GetAudits() []*OperationAudit { + if x != nil { + return x.Audits + } + return nil +} + +func (x *ListOperationAuditsResponse) GetNextCursor() string { + if x != nil { + return x.NextCursor + } + return "" +} + var File_agentcompose_v2_agentcompose_proto protoreflect.FileDescriptor const file_agentcompose_v2_agentcompose_proto_rawDesc = "" + @@ -17795,7 +18855,103 @@ const file_agentcompose_v2_agentcompose_proto_rawDesc = "" + "\vresponse_id\x18\x03 \x01(\tR\n" + "responseId\x12#\n" + "\rfinish_reason\x18\x04 \x01(\tR\ffinishReason\x12\x12\n" + - "\x04json\x18\x05 \x01(\tR\x04json*\x98\x01\n" + + "\x04json\x18\x05 \x01(\tR\x04json\"\xe4\x02\n" + + "\tAuthToken\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12 \n" + + "\vdescription\x18\x03 \x01(\tR\vdescription\x12\x12\n" + + "\x04role\x18\x04 \x01(\tR\x04role\x12\x16\n" + + "\x06origin\x18\x05 \x01(\tR\x06origin\x12-\n" + + "\x13created_by_token_id\x18\a \x01(\tR\x10createdByTokenId\x129\n" + + "\n" + + "created_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12-\n" + + "\x13revoked_by_token_id\x18\t \x01(\tR\x10revokedByTokenId\x129\n" + + "\n" + + "revoked_at\x18\n" + + " \x01(\v2\x1a.google.protobuf.TimestampR\trevokedAtJ\x04\b\x06\x10\aR\vfingerprint\"\x0f\n" + + "\rWhoAmIRequest\"\xad\x01\n" + + "\x0eWhoAmIResponse\x120\n" + + "\x05token\x18\x01 \x01(\v2\x1a.agentcompose.v2.AuthTokenR\x05token\x12\x12\n" + + "\x04role\x18\x02 \x01(\tR\x04role\x12\x16\n" + + "\x06origin\x18\x03 \x01(\tR\x06origin\x12=\n" + + "\x1aauthentication_initialized\x18\x04 \x01(\bR\x19authenticationInitialized\"w\n" + + "\bAuthRole\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + + "\vdescription\x18\x02 \x01(\tR\vdescription\x12\x1b\n" + + "\tread_only\x18\x03 \x01(\bR\breadOnly\x12\x18\n" + + "\abuiltin\x18\x04 \x01(\bR\abuiltin\"\x12\n" + + "\x10ListRolesRequest\"D\n" + + "\x11ListRolesResponse\x12/\n" + + "\x05roles\x18\x01 \x03(\v2\x19.agentcompose.v2.AuthRoleR\x05roles\"j\n" + + "\x11ListTokensRequest\x12'\n" + + "\x0finclude_revoked\x18\x01 \x01(\bR\x0eincludeRevoked\x12\x14\n" + + "\x05limit\x18\x02 \x01(\rR\x05limit\x12\x16\n" + + "\x06cursor\x18\x03 \x01(\tR\x06cursor\"i\n" + + "\x12ListTokensResponse\x122\n" + + "\x06tokens\x18\x01 \x03(\v2\x1a.agentcompose.v2.AuthTokenR\x06tokens\x12\x1f\n" + + "\vnext_cursor\x18\x02 \x01(\tR\n" + + "nextCursor\"\xa0\x01\n" + + "\x12CreateTokenRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + + "\vdescription\x18\x02 \x01(\tR\vdescription\x12\x12\n" + + "\x04role\x18\x03 \x01(\tR\x04role\x12\x14\n" + + "\x05token\x18\x04 \x01(\tR\x05token\x12*\n" + + "\x11client_request_id\x18\x05 \x01(\tR\x0fclientRequestId\"\x8c\x01\n" + + "\x13CreateTokenResponse\x12.\n" + + "\x04item\x18\x01 \x01(\v2\x1a.agentcompose.v2.AuthTokenR\x04item\x12\x18\n" + + "\acreated\x18\x02 \x01(\bR\acreated\x12+\n" + + "\x11idempotent_replay\x18\x03 \x01(\bR\x10idempotentReplay\"*\n" + + "\x12RevokeTokenRequest\x12\x14\n" + + "\x05token\x18\x01 \x01(\tR\x05token\"\x88\x01\n" + + "\x13RevokeTokenResponse\x12.\n" + + "\x04item\x18\x01 \x01(\v2\x1a.agentcompose.v2.AuthTokenR\x04item\x12\x18\n" + + "\arevoked\x18\x02 \x01(\bR\arevoked\x12'\n" + + "\x0falready_revoked\x18\x03 \x01(\bR\x0ealreadyRevoked\"\xd8\x04\n" + + "\x0eOperationAudit\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1a\n" + + "\bsequence\x18\x02 \x01(\x04R\bsequence\x12\x1d\n" + + "\n" + + "request_id\x18\x03 \x01(\tR\trequestId\x12\x19\n" + + "\btoken_id\x18\x04 \x01(\tR\atokenId\x12\x1d\n" + + "\n" + + "token_name\x18\x05 \x01(\tR\ttokenName\x12\x12\n" + + "\x04role\x18\x06 \x01(\tR\x04role\x12\x16\n" + + "\x06origin\x18\a \x01(\tR\x06origin\x12\x16\n" + + "\x06action\x18\b \x01(\tR\x06action\x12#\n" + + "\rresource_type\x18\t \x01(\tR\fresourceType\x12\x1f\n" + + "\vresource_id\x18\n" + + " \x01(\tR\n" + + "resourceId\x12\x1d\n" + + "\n" + + "project_id\x18\v \x01(\tR\tprojectId\x12\x16\n" + + "\x06status\x18\f \x01(\tR\x06status\x12\x1f\n" + + "\vparams_json\x18\r \x01(\tR\n" + + "paramsJson\x12!\n" + + "\fchanges_json\x18\x0e \x01(\tR\vchangesJson\x12\x1d\n" + + "\n" + + "error_code\x18\x0f \x01(\tR\terrorCode\x12#\n" + + "\rerror_message\x18\x10 \x01(\tR\ferrorMessage\x129\n" + + "\n" + + "started_at\x18\x11 \x01(\v2\x1a.google.protobuf.TimestampR\tstartedAt\x12=\n" + + "\fcompleted_at\x18\x12 \x01(\v2\x1a.google.protobuf.TimestampR\vcompletedAt\"\xf9\x02\n" + + "\x1aListOperationAuditsRequest\x12\x14\n" + + "\x05token\x18\x01 \x01(\tR\x05token\x12\x16\n" + + "\x06action\x18\x02 \x01(\tR\x06action\x12#\n" + + "\rresource_type\x18\x03 \x01(\tR\fresourceType\x12\x1f\n" + + "\vresource_id\x18\x04 \x01(\tR\n" + + "resourceId\x12\x1d\n" + + "\n" + + "project_id\x18\x05 \x01(\tR\tprojectId\x12\x16\n" + + "\x06status\x18\x06 \x01(\tR\x06status\x12?\n" + + "\rstarted_after\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\fstartedAfter\x12A\n" + + "\x0estarted_before\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\rstartedBefore\x12\x14\n" + + "\x05limit\x18\t \x01(\rR\x05limit\x12\x16\n" + + "\x06cursor\x18\n" + + " \x01(\tR\x06cursor\"w\n" + + "\x1bListOperationAuditsResponse\x127\n" + + "\x06audits\x18\x01 \x03(\v2\x1f.agentcompose.v2.OperationAuditR\x06audits\x12\x1f\n" + + "\vnext_cursor\x18\x02 \x01(\tR\n" + + "nextCursor*\x98\x01\n" + "\x19ProjectValidationSeverity\x12+\n" + "'PROJECT_VALIDATION_SEVERITY_UNSPECIFIED\x10\x00\x12'\n" + "#PROJECT_VALIDATION_SEVERITY_WARNING\x10\x01\x12%\n" + @@ -18016,7 +19172,15 @@ const file_agentcompose_v2_agentcompose_proto_rawDesc = "" + "LLMService\x12U\n" + "\bGenerate\x12#.agentcompose.v2.GenerateLLMRequest\x1a$.agentcompose.v2.GenerateLLMResponse2u\n" + "\x0fResourceService\x12b\n" + - "\tResolveID\x12).agentcompose.v2.ResolveResourceIDRequest\x1a*.agentcompose.v2.ResolveResourceIDResponseB4Z2agent-compose/proto/agentcompose/v2;agentcomposev2b\x06proto3" + "\tResolveID\x12).agentcompose.v2.ResolveResourceIDRequest\x1a*.agentcompose.v2.ResolveResourceIDResponse2\xa9\x04\n" + + "\vAuthService\x12I\n" + + "\x06WhoAmI\x12\x1e.agentcompose.v2.WhoAmIRequest\x1a\x1f.agentcompose.v2.WhoAmIResponse\x12R\n" + + "\tListRoles\x12!.agentcompose.v2.ListRolesRequest\x1a\".agentcompose.v2.ListRolesResponse\x12U\n" + + "\n" + + "ListTokens\x12\".agentcompose.v2.ListTokensRequest\x1a#.agentcompose.v2.ListTokensResponse\x12X\n" + + "\vCreateToken\x12#.agentcompose.v2.CreateTokenRequest\x1a$.agentcompose.v2.CreateTokenResponse\x12X\n" + + "\vRevokeToken\x12#.agentcompose.v2.RevokeTokenRequest\x1a$.agentcompose.v2.RevokeTokenResponse\x12p\n" + + "\x13ListOperationAudits\x12+.agentcompose.v2.ListOperationAuditsRequest\x1a,.agentcompose.v2.ListOperationAuditsResponseB4Z2agent-compose/proto/agentcompose/v2;agentcomposev2b\x06proto3" var ( file_agentcompose_v2_agentcompose_proto_rawDescOnce sync.Once @@ -18031,7 +19195,7 @@ func file_agentcompose_v2_agentcompose_proto_rawDescGZIP() []byte { } var file_agentcompose_v2_agentcompose_proto_enumTypes = make([]protoimpl.EnumInfo, 25) -var file_agentcompose_v2_agentcompose_proto_msgTypes = make([]protoimpl.MessageInfo, 226) +var file_agentcompose_v2_agentcompose_proto_msgTypes = make([]protoimpl.MessageInfo, 241) var file_agentcompose_v2_agentcompose_proto_goTypes = []any{ (ProjectValidationSeverity)(0), // 0: agentcompose.v2.ProjectValidationSeverity (ProjectChangeAction)(0), // 1: agentcompose.v2.ProjectChangeAction @@ -18272,19 +19436,34 @@ var file_agentcompose_v2_agentcompose_proto_goTypes = []any{ (*WatchSandboxResponse)(nil), // 236: agentcompose.v2.WatchSandboxResponse (*GenerateLLMRequest)(nil), // 237: agentcompose.v2.GenerateLLMRequest (*GenerateLLMResponse)(nil), // 238: agentcompose.v2.GenerateLLMResponse - nil, // 239: agentcompose.v2.ProjectVolumeSpec.LabelsEntry - nil, // 240: agentcompose.v2.ProjectVolumeSpec.OptionsEntry - nil, // 241: agentcompose.v2.BuildSpec.ArgsEntry - nil, // 242: agentcompose.v2.AttachHumanMessage.MetadataEntry - nil, // 243: agentcompose.v2.AttachError.DetailsEntry - nil, // 244: agentcompose.v2.BuildImageRequest.BuildArgsEntry - nil, // 245: agentcompose.v2.CreateVolumeRequest.LabelsEntry - nil, // 246: agentcompose.v2.CreateVolumeRequest.OptionsEntry - nil, // 247: agentcompose.v2.Volume.LabelsEntry - nil, // 248: agentcompose.v2.Volume.OptionsEntry - nil, // 249: agentcompose.v2.Image.LabelsEntry - nil, // 250: agentcompose.v2.CapabilityEndpoint.MetadataEntry - (*timestamppb.Timestamp)(nil), // 251: google.protobuf.Timestamp + (*AuthToken)(nil), // 239: agentcompose.v2.AuthToken + (*WhoAmIRequest)(nil), // 240: agentcompose.v2.WhoAmIRequest + (*WhoAmIResponse)(nil), // 241: agentcompose.v2.WhoAmIResponse + (*AuthRole)(nil), // 242: agentcompose.v2.AuthRole + (*ListRolesRequest)(nil), // 243: agentcompose.v2.ListRolesRequest + (*ListRolesResponse)(nil), // 244: agentcompose.v2.ListRolesResponse + (*ListTokensRequest)(nil), // 245: agentcompose.v2.ListTokensRequest + (*ListTokensResponse)(nil), // 246: agentcompose.v2.ListTokensResponse + (*CreateTokenRequest)(nil), // 247: agentcompose.v2.CreateTokenRequest + (*CreateTokenResponse)(nil), // 248: agentcompose.v2.CreateTokenResponse + (*RevokeTokenRequest)(nil), // 249: agentcompose.v2.RevokeTokenRequest + (*RevokeTokenResponse)(nil), // 250: agentcompose.v2.RevokeTokenResponse + (*OperationAudit)(nil), // 251: agentcompose.v2.OperationAudit + (*ListOperationAuditsRequest)(nil), // 252: agentcompose.v2.ListOperationAuditsRequest + (*ListOperationAuditsResponse)(nil), // 253: agentcompose.v2.ListOperationAuditsResponse + nil, // 254: agentcompose.v2.ProjectVolumeSpec.LabelsEntry + nil, // 255: agentcompose.v2.ProjectVolumeSpec.OptionsEntry + nil, // 256: agentcompose.v2.BuildSpec.ArgsEntry + nil, // 257: agentcompose.v2.AttachHumanMessage.MetadataEntry + nil, // 258: agentcompose.v2.AttachError.DetailsEntry + nil, // 259: agentcompose.v2.BuildImageRequest.BuildArgsEntry + nil, // 260: agentcompose.v2.CreateVolumeRequest.LabelsEntry + nil, // 261: agentcompose.v2.CreateVolumeRequest.OptionsEntry + nil, // 262: agentcompose.v2.Volume.LabelsEntry + nil, // 263: agentcompose.v2.Volume.OptionsEntry + nil, // 264: agentcompose.v2.Image.LabelsEntry + nil, // 265: agentcompose.v2.CapabilityEndpoint.MetadataEntry + (*timestamppb.Timestamp)(nil), // 266: google.protobuf.Timestamp } var file_agentcompose_v2_agentcompose_proto_depIdxs = []int32{ 72, // 0: agentcompose.v2.ValidateProjectRequest.spec:type_name -> agentcompose.v2.ProjectSpec @@ -18318,18 +19497,18 @@ var file_agentcompose_v2_agentcompose_proto_depIdxs = []int32{ 44, // 28: agentcompose.v2.ProjectAgent.latest_run:type_name -> agentcompose.v2.ProjectAgentLatestRun 3, // 29: agentcompose.v2.ProjectAgentLatestRun.status:type_name -> agentcompose.v2.RunStatus 4, // 30: agentcompose.v2.ProjectAgentLatestRun.source:type_name -> agentcompose.v2.RunSource - 251, // 31: agentcompose.v2.ProjectAgentLatestRun.at:type_name -> google.protobuf.Timestamp + 266, // 31: agentcompose.v2.ProjectAgentLatestRun.at:type_name -> google.protobuf.Timestamp 37, // 32: agentcompose.v2.GetSchedulerRequest.project:type_name -> agentcompose.v2.ProjectRef 45, // 33: agentcompose.v2.GetSchedulerResponse.scheduler:type_name -> agentcompose.v2.ProjectScheduler 83, // 34: agentcompose.v2.GetSchedulerResponse.spec:type_name -> agentcompose.v2.SchedulerSpec 48, // 35: agentcompose.v2.GetSchedulerResponse.triggers:type_name -> agentcompose.v2.ResolvedTrigger 84, // 36: agentcompose.v2.ResolvedTrigger.spec:type_name -> agentcompose.v2.TriggerSpec - 251, // 37: agentcompose.v2.ResolvedTrigger.next_fire_at:type_name -> google.protobuf.Timestamp - 251, // 38: agentcompose.v2.ResolvedTrigger.last_fired_at:type_name -> google.protobuf.Timestamp - 251, // 39: agentcompose.v2.SchedulerSummary.latest_run_at:type_name -> google.protobuf.Timestamp + 266, // 37: agentcompose.v2.ResolvedTrigger.next_fire_at:type_name -> google.protobuf.Timestamp + 266, // 38: agentcompose.v2.ResolvedTrigger.last_fired_at:type_name -> google.protobuf.Timestamp + 266, // 39: agentcompose.v2.SchedulerSummary.latest_run_at:type_name -> google.protobuf.Timestamp 50, // 40: agentcompose.v2.ListSchedulersResponse.schedulers:type_name -> agentcompose.v2.SchedulerSummary 37, // 41: agentcompose.v2.ListSchedulerEventsRequest.project:type_name -> agentcompose.v2.ProjectRef - 251, // 42: agentcompose.v2.SchedulerEvent.created_at:type_name -> google.protobuf.Timestamp + 266, // 42: agentcompose.v2.SchedulerEvent.created_at:type_name -> google.protobuf.Timestamp 53, // 43: agentcompose.v2.ListSchedulerEventsResponse.events:type_name -> agentcompose.v2.SchedulerEvent 37, // 44: agentcompose.v2.RunSchedulerRequest.project:type_name -> agentcompose.v2.ProjectRef 65, // 45: agentcompose.v2.RunSchedulerResponse.run:type_name -> agentcompose.v2.SchedulerRun @@ -18342,8 +19521,8 @@ var file_agentcompose_v2_agentcompose_proto_depIdxs = []int32{ 37, // 52: agentcompose.v2.StopSchedulerRunRequest.project:type_name -> agentcompose.v2.ProjectRef 65, // 53: agentcompose.v2.StopSchedulerRunResponse.run:type_name -> agentcompose.v2.SchedulerRun 5, // 54: agentcompose.v2.SchedulerRun.status:type_name -> agentcompose.v2.SchedulerRunStatus - 251, // 55: agentcompose.v2.SchedulerRun.started_at:type_name -> google.protobuf.Timestamp - 251, // 56: agentcompose.v2.SchedulerRun.completed_at:type_name -> google.protobuf.Timestamp + 266, // 55: agentcompose.v2.SchedulerRun.started_at:type_name -> google.protobuf.Timestamp + 266, // 56: agentcompose.v2.SchedulerRun.completed_at:type_name -> google.protobuf.Timestamp 37, // 57: agentcompose.v2.SetSchedulerEnabledRequest.project:type_name -> agentcompose.v2.ProjectRef 45, // 58: agentcompose.v2.SetSchedulerEnabledResponse.scheduler:type_name -> agentcompose.v2.ProjectScheduler 37, // 59: agentcompose.v2.SetSchedulerTriggerEnabledRequest.project:type_name -> agentcompose.v2.ProjectRef @@ -18369,9 +19548,9 @@ var file_agentcompose_v2_agentcompose_proto_depIdxs = []int32{ 6, // 79: agentcompose.v2.AgentSpec.status:type_name -> agentcompose.v2.AgentStatus 79, // 80: agentcompose.v2.MCPServerSpec.env:type_name -> agentcompose.v2.EnvVarSpec 79, // 81: agentcompose.v2.MCPServerSpec.headers:type_name -> agentcompose.v2.EnvVarSpec - 239, // 82: agentcompose.v2.ProjectVolumeSpec.labels:type_name -> agentcompose.v2.ProjectVolumeSpec.LabelsEntry - 240, // 83: agentcompose.v2.ProjectVolumeSpec.options:type_name -> agentcompose.v2.ProjectVolumeSpec.OptionsEntry - 241, // 84: agentcompose.v2.BuildSpec.args:type_name -> agentcompose.v2.BuildSpec.ArgsEntry + 254, // 82: agentcompose.v2.ProjectVolumeSpec.labels:type_name -> agentcompose.v2.ProjectVolumeSpec.LabelsEntry + 255, // 83: agentcompose.v2.ProjectVolumeSpec.options:type_name -> agentcompose.v2.ProjectVolumeSpec.OptionsEntry + 256, // 84: agentcompose.v2.BuildSpec.args:type_name -> agentcompose.v2.BuildSpec.ArgsEntry 84, // 85: agentcompose.v2.SchedulerSpec.triggers:type_name -> agentcompose.v2.TriggerSpec 85, // 86: agentcompose.v2.TriggerSpec.event:type_name -> agentcompose.v2.EventTriggerSpec 87, // 87: agentcompose.v2.DriverSpec.boxlite:type_name -> agentcompose.v2.BoxliteDriverSpec @@ -18411,16 +19590,16 @@ var file_agentcompose_v2_agentcompose_proto_depIdxs = []int32{ 3, // 121: agentcompose.v2.RunLogChunk.run_status:type_name -> agentcompose.v2.RunStatus 130, // 122: agentcompose.v2.StopRunResponse.run:type_name -> agentcompose.v2.RunDetail 9, // 123: agentcompose.v2.RunEvent.kind:type_name -> agentcompose.v2.RunEventKind - 251, // 124: agentcompose.v2.RunEvent.created_at:type_name -> google.protobuf.Timestamp + 266, // 124: agentcompose.v2.RunEvent.created_at:type_name -> google.protobuf.Timestamp 106, // 125: agentcompose.v2.ListRunEventsResponse.events:type_name -> agentcompose.v2.RunEvent 106, // 126: agentcompose.v2.ListSandboxRunEventsResponse.events:type_name -> agentcompose.v2.RunEvent 21, // 127: agentcompose.v2.SandboxPruneCandidate.kind:type_name -> agentcompose.v2.SandboxPruneCandidateKind - 251, // 128: agentcompose.v2.SandboxPruneCandidate.updated_at:type_name -> google.protobuf.Timestamp + 266, // 128: agentcompose.v2.SandboxPruneCandidate.updated_at:type_name -> google.protobuf.Timestamp 113, // 129: agentcompose.v2.PruneSandboxesResponse.matched:type_name -> agentcompose.v2.SandboxPruneCandidate 113, // 130: agentcompose.v2.PruneSandboxesResponse.skipped:type_name -> agentcompose.v2.SandboxPruneCandidate 128, // 131: agentcompose.v2.GetSandboxStatsResponse.stats:type_name -> agentcompose.v2.SandboxStats - 251, // 132: agentcompose.v2.Sandbox.created_at:type_name -> google.protobuf.Timestamp - 251, // 133: agentcompose.v2.Sandbox.updated_at:type_name -> google.protobuf.Timestamp + 266, // 132: agentcompose.v2.Sandbox.created_at:type_name -> google.protobuf.Timestamp + 266, // 133: agentcompose.v2.Sandbox.updated_at:type_name -> google.protobuf.Timestamp 119, // 134: agentcompose.v2.Sandbox.tags:type_name -> agentcompose.v2.SandboxTag 118, // 135: agentcompose.v2.ListSandboxesResponse.sandboxes:type_name -> agentcompose.v2.Sandbox 118, // 136: agentcompose.v2.GetSandboxResponse.sandbox:type_name -> agentcompose.v2.Sandbox @@ -18464,13 +19643,13 @@ var file_agentcompose_v2_agentcompose_proto_depIdxs = []int32{ 139, // 174: agentcompose.v2.ExecAttachStart.terminal_size:type_name -> agentcompose.v2.AttachTerminalSize 13, // 175: agentcompose.v2.ExecAttachStart.mode:type_name -> agentcompose.v2.AttachRunMode 139, // 176: agentcompose.v2.AttachResize.terminal_size:type_name -> agentcompose.v2.AttachTerminalSize - 242, // 177: agentcompose.v2.AttachHumanMessage.metadata:type_name -> agentcompose.v2.AttachHumanMessage.MetadataEntry + 257, // 177: agentcompose.v2.AttachHumanMessage.metadata:type_name -> agentcompose.v2.AttachHumanMessage.MetadataEntry 129, // 178: agentcompose.v2.AttachStarted.run:type_name -> agentcompose.v2.RunSummary 14, // 179: agentcompose.v2.AttachOutput.stream:type_name -> agentcompose.v2.StdioStream 96, // 180: agentcompose.v2.AttachOutput.transcript:type_name -> agentcompose.v2.TranscriptEvent 152, // 181: agentcompose.v2.AttachResult.exec_result:type_name -> agentcompose.v2.ExecResult 129, // 182: agentcompose.v2.AttachResult.run:type_name -> agentcompose.v2.RunSummary - 243, // 183: agentcompose.v2.AttachError.details:type_name -> agentcompose.v2.AttachError.DetailsEntry + 258, // 183: agentcompose.v2.AttachError.details:type_name -> agentcompose.v2.AttachError.DetailsEntry 133, // 184: agentcompose.v2.ExecResult.command:type_name -> agentcompose.v2.ExecCommand 15, // 185: agentcompose.v2.ListImagesRequest.store:type_name -> agentcompose.v2.ImageStoreKind 185, // 186: agentcompose.v2.ListImagesResponse.images:type_name -> agentcompose.v2.Image @@ -18484,7 +19663,7 @@ var file_agentcompose_v2_agentcompose_proto_depIdxs = []int32{ 185, // 194: agentcompose.v2.InspectImageResponse.image:type_name -> agentcompose.v2.Image 187, // 195: agentcompose.v2.InspectImageResponse.store_status:type_name -> agentcompose.v2.ImageStoreStatus 15, // 196: agentcompose.v2.RemoveImageRequest.store:type_name -> agentcompose.v2.ImageStoreKind - 244, // 197: agentcompose.v2.BuildImageRequest.build_args:type_name -> agentcompose.v2.BuildImageRequest.BuildArgsEntry + 259, // 197: agentcompose.v2.BuildImageRequest.build_args:type_name -> agentcompose.v2.BuildImageRequest.BuildArgsEntry 15, // 198: agentcompose.v2.BuildImageRequest.store:type_name -> agentcompose.v2.ImageStoreKind 186, // 199: agentcompose.v2.BuildImageRequest.platform:type_name -> agentcompose.v2.ImagePlatform 17, // 200: agentcompose.v2.BuildImageEvent.status:type_name -> agentcompose.v2.ImageOperationStatus @@ -18504,21 +19683,21 @@ var file_agentcompose_v2_agentcompose_proto_depIdxs = []int32{ 173, // 214: agentcompose.v2.CacheItem.references:type_name -> agentcompose.v2.CacheReference 20, // 215: agentcompose.v2.CacheReference.policy:type_name -> agentcompose.v2.CacheReferencePolicy 184, // 216: agentcompose.v2.ListVolumesResponse.volumes:type_name -> agentcompose.v2.Volume - 245, // 217: agentcompose.v2.CreateVolumeRequest.labels:type_name -> agentcompose.v2.CreateVolumeRequest.LabelsEntry - 246, // 218: agentcompose.v2.CreateVolumeRequest.options:type_name -> agentcompose.v2.CreateVolumeRequest.OptionsEntry + 260, // 217: agentcompose.v2.CreateVolumeRequest.labels:type_name -> agentcompose.v2.CreateVolumeRequest.LabelsEntry + 261, // 218: agentcompose.v2.CreateVolumeRequest.options:type_name -> agentcompose.v2.CreateVolumeRequest.OptionsEntry 184, // 219: agentcompose.v2.CreateVolumeResponse.volume:type_name -> agentcompose.v2.Volume 184, // 220: agentcompose.v2.InspectVolumeResponse.volume:type_name -> agentcompose.v2.Volume 184, // 221: agentcompose.v2.PruneVolumesResponse.matched:type_name -> agentcompose.v2.Volume 184, // 222: agentcompose.v2.PruneVolumesResponse.removed:type_name -> agentcompose.v2.Volume 184, // 223: agentcompose.v2.PruneVolumesResponse.skipped:type_name -> agentcompose.v2.Volume - 247, // 224: agentcompose.v2.Volume.labels:type_name -> agentcompose.v2.Volume.LabelsEntry - 248, // 225: agentcompose.v2.Volume.options:type_name -> agentcompose.v2.Volume.OptionsEntry + 262, // 224: agentcompose.v2.Volume.labels:type_name -> agentcompose.v2.Volume.LabelsEntry + 263, // 225: agentcompose.v2.Volume.options:type_name -> agentcompose.v2.Volume.OptionsEntry 15, // 226: agentcompose.v2.Image.store:type_name -> agentcompose.v2.ImageStoreKind 16, // 227: agentcompose.v2.Image.availability_status:type_name -> agentcompose.v2.ImageAvailabilityStatus 186, // 228: agentcompose.v2.Image.platform:type_name -> agentcompose.v2.ImagePlatform 188, // 229: agentcompose.v2.Image.docker:type_name -> agentcompose.v2.DockerImageStatus 189, // 230: agentcompose.v2.Image.oci:type_name -> agentcompose.v2.OCIImageStatus - 249, // 231: agentcompose.v2.Image.labels:type_name -> agentcompose.v2.Image.LabelsEntry + 264, // 231: agentcompose.v2.Image.labels:type_name -> agentcompose.v2.Image.LabelsEntry 15, // 232: agentcompose.v2.ImageStoreStatus.store:type_name -> agentcompose.v2.ImageStoreKind 90, // 233: agentcompose.v2.StartRunRequest.run:type_name -> agentcompose.v2.RunAgentRequest 129, // 234: agentcompose.v2.StartRunResponse.run:type_name -> agentcompose.v2.RunSummary @@ -18526,7 +19705,7 @@ var file_agentcompose_v2_agentcompose_proto_depIdxs = []int32{ 198, // 236: agentcompose.v2.ResolveResourceIDResponse.targets:type_name -> agentcompose.v2.ResourceTarget 23, // 237: agentcompose.v2.ResourceTarget.kind:type_name -> agentcompose.v2.ResourceKind 201, // 238: agentcompose.v2.DashboardOverview.runs:type_name -> agentcompose.v2.RunOverview - 251, // 239: agentcompose.v2.DashboardOverview.updated_at:type_name -> google.protobuf.Timestamp + 266, // 239: agentcompose.v2.DashboardOverview.updated_at:type_name -> google.protobuf.Timestamp 202, // 240: agentcompose.v2.GetDashboardOverviewResponse.overview:type_name -> agentcompose.v2.DashboardOverview 202, // 241: agentcompose.v2.WatchDashboardOverviewResponse.overview:type_name -> agentcompose.v2.DashboardOverview 79, // 242: agentcompose.v2.GetGlobalEnvResponse.env:type_name -> agentcompose.v2.EnvVarSpec @@ -18534,16 +19713,16 @@ var file_agentcompose_v2_agentcompose_proto_depIdxs = []int32{ 79, // 244: agentcompose.v2.UpdateGlobalEnvResponse.env:type_name -> agentcompose.v2.EnvVarSpec 210, // 245: agentcompose.v2.GetCapabilityGatewayConfigResponse.config:type_name -> agentcompose.v2.CapabilityGatewayConfig 210, // 246: agentcompose.v2.UpdateCapabilityGatewayConfigResponse.config:type_name -> agentcompose.v2.CapabilityGatewayConfig - 251, // 247: agentcompose.v2.WorkspacePreset.created_at:type_name -> google.protobuf.Timestamp - 251, // 248: agentcompose.v2.WorkspacePreset.updated_at:type_name -> google.protobuf.Timestamp + 266, // 247: agentcompose.v2.WorkspacePreset.created_at:type_name -> google.protobuf.Timestamp + 266, // 248: agentcompose.v2.WorkspacePreset.updated_at:type_name -> google.protobuf.Timestamp 214, // 249: agentcompose.v2.ListWorkspacePresetsResponse.presets:type_name -> agentcompose.v2.WorkspacePreset 214, // 250: agentcompose.v2.WorkspacePresetResponse.preset:type_name -> agentcompose.v2.WorkspacePreset 225, // 251: agentcompose.v2.ListCapabilitySetsResponse.capsets:type_name -> agentcompose.v2.CapabilitySet - 250, // 252: agentcompose.v2.CapabilityEndpoint.metadata:type_name -> agentcompose.v2.CapabilityEndpoint.MetadataEntry + 265, // 252: agentcompose.v2.CapabilityEndpoint.metadata:type_name -> agentcompose.v2.CapabilityEndpoint.MetadataEntry 228, // 253: agentcompose.v2.CapabilityMethod.endpoints:type_name -> agentcompose.v2.CapabilityEndpoint 229, // 254: agentcompose.v2.GetCapabilityCatalogResponse.methods:type_name -> agentcompose.v2.CapabilityMethod - 251, // 255: agentcompose.v2.SandboxHistoryCell.created_at:type_name -> google.protobuf.Timestamp - 251, // 256: agentcompose.v2.SandboxHistoryEvent.created_at:type_name -> google.protobuf.Timestamp + 266, // 255: agentcompose.v2.SandboxHistoryCell.created_at:type_name -> google.protobuf.Timestamp + 266, // 256: agentcompose.v2.SandboxHistoryEvent.created_at:type_name -> google.protobuf.Timestamp 232, // 257: agentcompose.v2.ListSandboxHistoryResponse.cells:type_name -> agentcompose.v2.SandboxHistoryCell 233, // 258: agentcompose.v2.ListSandboxHistoryResponse.events:type_name -> agentcompose.v2.SandboxHistoryEvent 24, // 259: agentcompose.v2.WatchSandboxResponse.event_type:type_name -> agentcompose.v2.SandboxWatchEventType @@ -18551,145 +19730,169 @@ var file_agentcompose_v2_agentcompose_proto_depIdxs = []int32{ 232, // 261: agentcompose.v2.WatchSandboxResponse.cell:type_name -> agentcompose.v2.SandboxHistoryCell 233, // 262: agentcompose.v2.WatchSandboxResponse.event:type_name -> agentcompose.v2.SandboxHistoryEvent 14, // 263: agentcompose.v2.WatchSandboxResponse.stream:type_name -> agentcompose.v2.StdioStream - 25, // 264: agentcompose.v2.ProjectService.ValidateProject:input_type -> agentcompose.v2.ValidateProjectRequest - 27, // 265: agentcompose.v2.ProjectService.ApplyProject:input_type -> agentcompose.v2.ApplyProjectRequest - 29, // 266: agentcompose.v2.ProjectService.GetProject:input_type -> agentcompose.v2.GetProjectRequest - 31, // 267: agentcompose.v2.ProjectService.ListProjects:input_type -> agentcompose.v2.ListProjectsRequest - 33, // 268: agentcompose.v2.ProjectService.RemoveProject:input_type -> agentcompose.v2.RemoveProjectRequest - 35, // 269: agentcompose.v2.ProjectService.WatchProject:input_type -> agentcompose.v2.WatchProjectRequest - 46, // 270: agentcompose.v2.ProjectService.GetScheduler:input_type -> agentcompose.v2.GetSchedulerRequest - 49, // 271: agentcompose.v2.ProjectService.ListSchedulers:input_type -> agentcompose.v2.ListSchedulersRequest - 52, // 272: agentcompose.v2.ProjectService.ListSchedulerEvents:input_type -> agentcompose.v2.ListSchedulerEventsRequest - 55, // 273: agentcompose.v2.ProjectService.RunScheduler:input_type -> agentcompose.v2.RunSchedulerRequest - 57, // 274: agentcompose.v2.ProjectService.StartSchedulerRun:input_type -> agentcompose.v2.StartSchedulerRunRequest - 59, // 275: agentcompose.v2.ProjectService.GetSchedulerRun:input_type -> agentcompose.v2.GetSchedulerRunRequest - 61, // 276: agentcompose.v2.ProjectService.ListSchedulerRuns:input_type -> agentcompose.v2.ListSchedulerRunsRequest - 63, // 277: agentcompose.v2.ProjectService.StopSchedulerRun:input_type -> agentcompose.v2.StopSchedulerRunRequest - 66, // 278: agentcompose.v2.ProjectService.SetSchedulerEnabled:input_type -> agentcompose.v2.SetSchedulerEnabledRequest - 68, // 279: agentcompose.v2.ProjectService.SetSchedulerTriggerEnabled:input_type -> agentcompose.v2.SetSchedulerTriggerEnabledRequest - 90, // 280: agentcompose.v2.RunService.RunAgent:input_type -> agentcompose.v2.RunAgentRequest - 193, // 281: agentcompose.v2.RunService.StartRun:input_type -> agentcompose.v2.StartRunRequest - 90, // 282: agentcompose.v2.RunService.RunAgentStream:input_type -> agentcompose.v2.RunAgentRequest - 93, // 283: agentcompose.v2.RunService.RunAttach:input_type -> agentcompose.v2.RunAttachRequest - 97, // 284: agentcompose.v2.RunService.GetRun:input_type -> agentcompose.v2.GetRunRequest - 99, // 285: agentcompose.v2.RunService.ListRuns:input_type -> agentcompose.v2.ListRunsRequest - 101, // 286: agentcompose.v2.RunService.FollowRunLogs:input_type -> agentcompose.v2.FollowRunLogsRequest - 103, // 287: agentcompose.v2.RunService.StopRun:input_type -> agentcompose.v2.StopRunRequest - 105, // 288: agentcompose.v2.RunService.ListRunEvents:input_type -> agentcompose.v2.ListRunEventsRequest - 108, // 289: agentcompose.v2.RunService.ListSandboxRunEvents:input_type -> agentcompose.v2.ListSandboxRunEventsRequest - 131, // 290: agentcompose.v2.ExecService.Exec:input_type -> agentcompose.v2.ExecRequest - 131, // 291: agentcompose.v2.ExecService.ExecStream:input_type -> agentcompose.v2.ExecRequest - 136, // 292: agentcompose.v2.ExecService.ExecAttach:input_type -> agentcompose.v2.ExecAttachRequest - 153, // 293: agentcompose.v2.ImageService.ListImages:input_type -> agentcompose.v2.ListImagesRequest - 155, // 294: agentcompose.v2.ImageService.PullImage:input_type -> agentcompose.v2.PullImageRequest - 157, // 295: agentcompose.v2.ImageService.InspectImage:input_type -> agentcompose.v2.InspectImageRequest - 159, // 296: agentcompose.v2.ImageService.RemoveImage:input_type -> agentcompose.v2.RemoveImageRequest - 161, // 297: agentcompose.v2.ImageService.BuildImage:input_type -> agentcompose.v2.BuildImageRequest - 164, // 298: agentcompose.v2.CacheService.ListCaches:input_type -> agentcompose.v2.ListCachesRequest - 166, // 299: agentcompose.v2.CacheService.InspectCache:input_type -> agentcompose.v2.InspectCacheRequest - 168, // 300: agentcompose.v2.CacheService.PruneCaches:input_type -> agentcompose.v2.PruneCachesRequest - 170, // 301: agentcompose.v2.CacheService.RemoveCache:input_type -> agentcompose.v2.RemoveCacheRequest - 174, // 302: agentcompose.v2.VolumeService.ListVolumes:input_type -> agentcompose.v2.ListVolumesRequest - 176, // 303: agentcompose.v2.VolumeService.CreateVolume:input_type -> agentcompose.v2.CreateVolumeRequest - 178, // 304: agentcompose.v2.VolumeService.InspectVolume:input_type -> agentcompose.v2.InspectVolumeRequest - 180, // 305: agentcompose.v2.VolumeService.RemoveVolume:input_type -> agentcompose.v2.RemoveVolumeRequest - 182, // 306: agentcompose.v2.VolumeService.PruneVolumes:input_type -> agentcompose.v2.PruneVolumesRequest - 110, // 307: agentcompose.v2.SandboxService.RemoveSandbox:input_type -> agentcompose.v2.RemoveSandboxRequest - 112, // 308: agentcompose.v2.SandboxService.PruneSandboxes:input_type -> agentcompose.v2.PruneSandboxesRequest - 115, // 309: agentcompose.v2.SandboxService.GetSandboxStats:input_type -> agentcompose.v2.GetSandboxStatsRequest - 117, // 310: agentcompose.v2.SandboxService.GetSandbox:input_type -> agentcompose.v2.GetSandboxRequest - 123, // 311: agentcompose.v2.SandboxService.StopSandbox:input_type -> agentcompose.v2.StopSandboxRequest - 125, // 312: agentcompose.v2.SandboxService.ResumeSandbox:input_type -> agentcompose.v2.ResumeSandboxRequest - 120, // 313: agentcompose.v2.SandboxService.ListSandboxes:input_type -> agentcompose.v2.ListSandboxesRequest - 231, // 314: agentcompose.v2.SandboxService.ListSandboxHistory:input_type -> agentcompose.v2.ListSandboxHistoryRequest - 235, // 315: agentcompose.v2.SandboxService.WatchSandbox:input_type -> agentcompose.v2.WatchSandboxRequest - 199, // 316: agentcompose.v2.DashboardService.GetDashboardOverview:input_type -> agentcompose.v2.GetDashboardOverviewRequest - 200, // 317: agentcompose.v2.DashboardService.WatchDashboardOverview:input_type -> agentcompose.v2.WatchDashboardOverviewRequest - 205, // 318: agentcompose.v2.SettingsService.GetGlobalEnv:input_type -> agentcompose.v2.GetGlobalEnvRequest - 207, // 319: agentcompose.v2.SettingsService.UpdateGlobalEnv:input_type -> agentcompose.v2.UpdateGlobalEnvRequest - 209, // 320: agentcompose.v2.SettingsService.GetCapabilityGatewayConfig:input_type -> agentcompose.v2.GetCapabilityGatewayConfigRequest - 212, // 321: agentcompose.v2.SettingsService.UpdateCapabilityGatewayConfig:input_type -> agentcompose.v2.UpdateCapabilityGatewayConfigRequest - 215, // 322: agentcompose.v2.SettingsService.ListWorkspacePresets:input_type -> agentcompose.v2.ListWorkspacePresetsRequest - 217, // 323: agentcompose.v2.SettingsService.CreateWorkspacePreset:input_type -> agentcompose.v2.CreateWorkspacePresetRequest - 218, // 324: agentcompose.v2.SettingsService.UpdateWorkspacePreset:input_type -> agentcompose.v2.UpdateWorkspacePresetRequest - 219, // 325: agentcompose.v2.SettingsService.DeleteWorkspacePreset:input_type -> agentcompose.v2.DeleteWorkspacePresetRequest - 222, // 326: agentcompose.v2.CapabilityService.GetCapabilityStatus:input_type -> agentcompose.v2.GetCapabilityStatusRequest - 224, // 327: agentcompose.v2.CapabilityService.ListCapabilitySets:input_type -> agentcompose.v2.ListCapabilitySetsRequest - 227, // 328: agentcompose.v2.CapabilityService.GetCapabilityCatalog:input_type -> agentcompose.v2.GetCapabilityCatalogRequest - 237, // 329: agentcompose.v2.LLMService.Generate:input_type -> agentcompose.v2.GenerateLLMRequest - 196, // 330: agentcompose.v2.ResourceService.ResolveID:input_type -> agentcompose.v2.ResolveResourceIDRequest - 26, // 331: agentcompose.v2.ProjectService.ValidateProject:output_type -> agentcompose.v2.ValidateProjectResponse - 28, // 332: agentcompose.v2.ProjectService.ApplyProject:output_type -> agentcompose.v2.ApplyProjectResponse - 30, // 333: agentcompose.v2.ProjectService.GetProject:output_type -> agentcompose.v2.GetProjectResponse - 32, // 334: agentcompose.v2.ProjectService.ListProjects:output_type -> agentcompose.v2.ListProjectsResponse - 34, // 335: agentcompose.v2.ProjectService.RemoveProject:output_type -> agentcompose.v2.RemoveProjectResponse - 36, // 336: agentcompose.v2.ProjectService.WatchProject:output_type -> agentcompose.v2.WatchProjectResponse - 47, // 337: agentcompose.v2.ProjectService.GetScheduler:output_type -> agentcompose.v2.GetSchedulerResponse - 51, // 338: agentcompose.v2.ProjectService.ListSchedulers:output_type -> agentcompose.v2.ListSchedulersResponse - 54, // 339: agentcompose.v2.ProjectService.ListSchedulerEvents:output_type -> agentcompose.v2.ListSchedulerEventsResponse - 56, // 340: agentcompose.v2.ProjectService.RunScheduler:output_type -> agentcompose.v2.RunSchedulerResponse - 58, // 341: agentcompose.v2.ProjectService.StartSchedulerRun:output_type -> agentcompose.v2.StartSchedulerRunResponse - 60, // 342: agentcompose.v2.ProjectService.GetSchedulerRun:output_type -> agentcompose.v2.GetSchedulerRunResponse - 62, // 343: agentcompose.v2.ProjectService.ListSchedulerRuns:output_type -> agentcompose.v2.ListSchedulerRunsResponse - 64, // 344: agentcompose.v2.ProjectService.StopSchedulerRun:output_type -> agentcompose.v2.StopSchedulerRunResponse - 67, // 345: agentcompose.v2.ProjectService.SetSchedulerEnabled:output_type -> agentcompose.v2.SetSchedulerEnabledResponse - 69, // 346: agentcompose.v2.ProjectService.SetSchedulerTriggerEnabled:output_type -> agentcompose.v2.SetSchedulerTriggerEnabledResponse - 91, // 347: agentcompose.v2.RunService.RunAgent:output_type -> agentcompose.v2.RunAgentResponse - 194, // 348: agentcompose.v2.RunService.StartRun:output_type -> agentcompose.v2.StartRunResponse - 92, // 349: agentcompose.v2.RunService.RunAgentStream:output_type -> agentcompose.v2.RunAgentStreamResponse - 94, // 350: agentcompose.v2.RunService.RunAttach:output_type -> agentcompose.v2.RunAttachResponse - 98, // 351: agentcompose.v2.RunService.GetRun:output_type -> agentcompose.v2.GetRunResponse - 100, // 352: agentcompose.v2.RunService.ListRuns:output_type -> agentcompose.v2.ListRunsResponse - 102, // 353: agentcompose.v2.RunService.FollowRunLogs:output_type -> agentcompose.v2.RunLogChunk - 104, // 354: agentcompose.v2.RunService.StopRun:output_type -> agentcompose.v2.StopRunResponse - 107, // 355: agentcompose.v2.RunService.ListRunEvents:output_type -> agentcompose.v2.ListRunEventsResponse - 109, // 356: agentcompose.v2.RunService.ListSandboxRunEvents:output_type -> agentcompose.v2.ListSandboxRunEventsResponse - 134, // 357: agentcompose.v2.ExecService.Exec:output_type -> agentcompose.v2.ExecResponse - 135, // 358: agentcompose.v2.ExecService.ExecStream:output_type -> agentcompose.v2.ExecStreamResponse - 137, // 359: agentcompose.v2.ExecService.ExecAttach:output_type -> agentcompose.v2.ExecAttachResponse - 154, // 360: agentcompose.v2.ImageService.ListImages:output_type -> agentcompose.v2.ListImagesResponse - 156, // 361: agentcompose.v2.ImageService.PullImage:output_type -> agentcompose.v2.PullImageResponse - 158, // 362: agentcompose.v2.ImageService.InspectImage:output_type -> agentcompose.v2.InspectImageResponse - 160, // 363: agentcompose.v2.ImageService.RemoveImage:output_type -> agentcompose.v2.RemoveImageResponse - 162, // 364: agentcompose.v2.ImageService.BuildImage:output_type -> agentcompose.v2.BuildImageEvent - 165, // 365: agentcompose.v2.CacheService.ListCaches:output_type -> agentcompose.v2.ListCachesResponse - 167, // 366: agentcompose.v2.CacheService.InspectCache:output_type -> agentcompose.v2.InspectCacheResponse - 169, // 367: agentcompose.v2.CacheService.PruneCaches:output_type -> agentcompose.v2.PruneCachesResponse - 171, // 368: agentcompose.v2.CacheService.RemoveCache:output_type -> agentcompose.v2.RemoveCacheResponse - 175, // 369: agentcompose.v2.VolumeService.ListVolumes:output_type -> agentcompose.v2.ListVolumesResponse - 177, // 370: agentcompose.v2.VolumeService.CreateVolume:output_type -> agentcompose.v2.CreateVolumeResponse - 179, // 371: agentcompose.v2.VolumeService.InspectVolume:output_type -> agentcompose.v2.InspectVolumeResponse - 181, // 372: agentcompose.v2.VolumeService.RemoveVolume:output_type -> agentcompose.v2.RemoveVolumeResponse - 183, // 373: agentcompose.v2.VolumeService.PruneVolumes:output_type -> agentcompose.v2.PruneVolumesResponse - 111, // 374: agentcompose.v2.SandboxService.RemoveSandbox:output_type -> agentcompose.v2.RemoveSandboxResponse - 114, // 375: agentcompose.v2.SandboxService.PruneSandboxes:output_type -> agentcompose.v2.PruneSandboxesResponse - 116, // 376: agentcompose.v2.SandboxService.GetSandboxStats:output_type -> agentcompose.v2.GetSandboxStatsResponse - 122, // 377: agentcompose.v2.SandboxService.GetSandbox:output_type -> agentcompose.v2.GetSandboxResponse - 124, // 378: agentcompose.v2.SandboxService.StopSandbox:output_type -> agentcompose.v2.StopSandboxResponse - 126, // 379: agentcompose.v2.SandboxService.ResumeSandbox:output_type -> agentcompose.v2.ResumeSandboxResponse - 121, // 380: agentcompose.v2.SandboxService.ListSandboxes:output_type -> agentcompose.v2.ListSandboxesResponse - 234, // 381: agentcompose.v2.SandboxService.ListSandboxHistory:output_type -> agentcompose.v2.ListSandboxHistoryResponse - 236, // 382: agentcompose.v2.SandboxService.WatchSandbox:output_type -> agentcompose.v2.WatchSandboxResponse - 203, // 383: agentcompose.v2.DashboardService.GetDashboardOverview:output_type -> agentcompose.v2.GetDashboardOverviewResponse - 204, // 384: agentcompose.v2.DashboardService.WatchDashboardOverview:output_type -> agentcompose.v2.WatchDashboardOverviewResponse - 206, // 385: agentcompose.v2.SettingsService.GetGlobalEnv:output_type -> agentcompose.v2.GetGlobalEnvResponse - 208, // 386: agentcompose.v2.SettingsService.UpdateGlobalEnv:output_type -> agentcompose.v2.UpdateGlobalEnvResponse - 211, // 387: agentcompose.v2.SettingsService.GetCapabilityGatewayConfig:output_type -> agentcompose.v2.GetCapabilityGatewayConfigResponse - 213, // 388: agentcompose.v2.SettingsService.UpdateCapabilityGatewayConfig:output_type -> agentcompose.v2.UpdateCapabilityGatewayConfigResponse - 216, // 389: agentcompose.v2.SettingsService.ListWorkspacePresets:output_type -> agentcompose.v2.ListWorkspacePresetsResponse - 221, // 390: agentcompose.v2.SettingsService.CreateWorkspacePreset:output_type -> agentcompose.v2.WorkspacePresetResponse - 221, // 391: agentcompose.v2.SettingsService.UpdateWorkspacePreset:output_type -> agentcompose.v2.WorkspacePresetResponse - 220, // 392: agentcompose.v2.SettingsService.DeleteWorkspacePreset:output_type -> agentcompose.v2.DeleteWorkspacePresetResponse - 223, // 393: agentcompose.v2.CapabilityService.GetCapabilityStatus:output_type -> agentcompose.v2.CapabilityStatusResponse - 226, // 394: agentcompose.v2.CapabilityService.ListCapabilitySets:output_type -> agentcompose.v2.ListCapabilitySetsResponse - 230, // 395: agentcompose.v2.CapabilityService.GetCapabilityCatalog:output_type -> agentcompose.v2.GetCapabilityCatalogResponse - 238, // 396: agentcompose.v2.LLMService.Generate:output_type -> agentcompose.v2.GenerateLLMResponse - 197, // 397: agentcompose.v2.ResourceService.ResolveID:output_type -> agentcompose.v2.ResolveResourceIDResponse - 331, // [331:398] is the sub-list for method output_type - 264, // [264:331] is the sub-list for method input_type - 264, // [264:264] is the sub-list for extension type_name - 264, // [264:264] is the sub-list for extension extendee - 0, // [0:264] is the sub-list for field type_name + 266, // 264: agentcompose.v2.AuthToken.created_at:type_name -> google.protobuf.Timestamp + 266, // 265: agentcompose.v2.AuthToken.revoked_at:type_name -> google.protobuf.Timestamp + 239, // 266: agentcompose.v2.WhoAmIResponse.token:type_name -> agentcompose.v2.AuthToken + 242, // 267: agentcompose.v2.ListRolesResponse.roles:type_name -> agentcompose.v2.AuthRole + 239, // 268: agentcompose.v2.ListTokensResponse.tokens:type_name -> agentcompose.v2.AuthToken + 239, // 269: agentcompose.v2.CreateTokenResponse.item:type_name -> agentcompose.v2.AuthToken + 239, // 270: agentcompose.v2.RevokeTokenResponse.item:type_name -> agentcompose.v2.AuthToken + 266, // 271: agentcompose.v2.OperationAudit.started_at:type_name -> google.protobuf.Timestamp + 266, // 272: agentcompose.v2.OperationAudit.completed_at:type_name -> google.protobuf.Timestamp + 266, // 273: agentcompose.v2.ListOperationAuditsRequest.started_after:type_name -> google.protobuf.Timestamp + 266, // 274: agentcompose.v2.ListOperationAuditsRequest.started_before:type_name -> google.protobuf.Timestamp + 251, // 275: agentcompose.v2.ListOperationAuditsResponse.audits:type_name -> agentcompose.v2.OperationAudit + 25, // 276: agentcompose.v2.ProjectService.ValidateProject:input_type -> agentcompose.v2.ValidateProjectRequest + 27, // 277: agentcompose.v2.ProjectService.ApplyProject:input_type -> agentcompose.v2.ApplyProjectRequest + 29, // 278: agentcompose.v2.ProjectService.GetProject:input_type -> agentcompose.v2.GetProjectRequest + 31, // 279: agentcompose.v2.ProjectService.ListProjects:input_type -> agentcompose.v2.ListProjectsRequest + 33, // 280: agentcompose.v2.ProjectService.RemoveProject:input_type -> agentcompose.v2.RemoveProjectRequest + 35, // 281: agentcompose.v2.ProjectService.WatchProject:input_type -> agentcompose.v2.WatchProjectRequest + 46, // 282: agentcompose.v2.ProjectService.GetScheduler:input_type -> agentcompose.v2.GetSchedulerRequest + 49, // 283: agentcompose.v2.ProjectService.ListSchedulers:input_type -> agentcompose.v2.ListSchedulersRequest + 52, // 284: agentcompose.v2.ProjectService.ListSchedulerEvents:input_type -> agentcompose.v2.ListSchedulerEventsRequest + 55, // 285: agentcompose.v2.ProjectService.RunScheduler:input_type -> agentcompose.v2.RunSchedulerRequest + 57, // 286: agentcompose.v2.ProjectService.StartSchedulerRun:input_type -> agentcompose.v2.StartSchedulerRunRequest + 59, // 287: agentcompose.v2.ProjectService.GetSchedulerRun:input_type -> agentcompose.v2.GetSchedulerRunRequest + 61, // 288: agentcompose.v2.ProjectService.ListSchedulerRuns:input_type -> agentcompose.v2.ListSchedulerRunsRequest + 63, // 289: agentcompose.v2.ProjectService.StopSchedulerRun:input_type -> agentcompose.v2.StopSchedulerRunRequest + 66, // 290: agentcompose.v2.ProjectService.SetSchedulerEnabled:input_type -> agentcompose.v2.SetSchedulerEnabledRequest + 68, // 291: agentcompose.v2.ProjectService.SetSchedulerTriggerEnabled:input_type -> agentcompose.v2.SetSchedulerTriggerEnabledRequest + 90, // 292: agentcompose.v2.RunService.RunAgent:input_type -> agentcompose.v2.RunAgentRequest + 193, // 293: agentcompose.v2.RunService.StartRun:input_type -> agentcompose.v2.StartRunRequest + 90, // 294: agentcompose.v2.RunService.RunAgentStream:input_type -> agentcompose.v2.RunAgentRequest + 93, // 295: agentcompose.v2.RunService.RunAttach:input_type -> agentcompose.v2.RunAttachRequest + 97, // 296: agentcompose.v2.RunService.GetRun:input_type -> agentcompose.v2.GetRunRequest + 99, // 297: agentcompose.v2.RunService.ListRuns:input_type -> agentcompose.v2.ListRunsRequest + 101, // 298: agentcompose.v2.RunService.FollowRunLogs:input_type -> agentcompose.v2.FollowRunLogsRequest + 103, // 299: agentcompose.v2.RunService.StopRun:input_type -> agentcompose.v2.StopRunRequest + 105, // 300: agentcompose.v2.RunService.ListRunEvents:input_type -> agentcompose.v2.ListRunEventsRequest + 108, // 301: agentcompose.v2.RunService.ListSandboxRunEvents:input_type -> agentcompose.v2.ListSandboxRunEventsRequest + 131, // 302: agentcompose.v2.ExecService.Exec:input_type -> agentcompose.v2.ExecRequest + 131, // 303: agentcompose.v2.ExecService.ExecStream:input_type -> agentcompose.v2.ExecRequest + 136, // 304: agentcompose.v2.ExecService.ExecAttach:input_type -> agentcompose.v2.ExecAttachRequest + 153, // 305: agentcompose.v2.ImageService.ListImages:input_type -> agentcompose.v2.ListImagesRequest + 155, // 306: agentcompose.v2.ImageService.PullImage:input_type -> agentcompose.v2.PullImageRequest + 157, // 307: agentcompose.v2.ImageService.InspectImage:input_type -> agentcompose.v2.InspectImageRequest + 159, // 308: agentcompose.v2.ImageService.RemoveImage:input_type -> agentcompose.v2.RemoveImageRequest + 161, // 309: agentcompose.v2.ImageService.BuildImage:input_type -> agentcompose.v2.BuildImageRequest + 164, // 310: agentcompose.v2.CacheService.ListCaches:input_type -> agentcompose.v2.ListCachesRequest + 166, // 311: agentcompose.v2.CacheService.InspectCache:input_type -> agentcompose.v2.InspectCacheRequest + 168, // 312: agentcompose.v2.CacheService.PruneCaches:input_type -> agentcompose.v2.PruneCachesRequest + 170, // 313: agentcompose.v2.CacheService.RemoveCache:input_type -> agentcompose.v2.RemoveCacheRequest + 174, // 314: agentcompose.v2.VolumeService.ListVolumes:input_type -> agentcompose.v2.ListVolumesRequest + 176, // 315: agentcompose.v2.VolumeService.CreateVolume:input_type -> agentcompose.v2.CreateVolumeRequest + 178, // 316: agentcompose.v2.VolumeService.InspectVolume:input_type -> agentcompose.v2.InspectVolumeRequest + 180, // 317: agentcompose.v2.VolumeService.RemoveVolume:input_type -> agentcompose.v2.RemoveVolumeRequest + 182, // 318: agentcompose.v2.VolumeService.PruneVolumes:input_type -> agentcompose.v2.PruneVolumesRequest + 110, // 319: agentcompose.v2.SandboxService.RemoveSandbox:input_type -> agentcompose.v2.RemoveSandboxRequest + 112, // 320: agentcompose.v2.SandboxService.PruneSandboxes:input_type -> agentcompose.v2.PruneSandboxesRequest + 115, // 321: agentcompose.v2.SandboxService.GetSandboxStats:input_type -> agentcompose.v2.GetSandboxStatsRequest + 117, // 322: agentcompose.v2.SandboxService.GetSandbox:input_type -> agentcompose.v2.GetSandboxRequest + 123, // 323: agentcompose.v2.SandboxService.StopSandbox:input_type -> agentcompose.v2.StopSandboxRequest + 125, // 324: agentcompose.v2.SandboxService.ResumeSandbox:input_type -> agentcompose.v2.ResumeSandboxRequest + 120, // 325: agentcompose.v2.SandboxService.ListSandboxes:input_type -> agentcompose.v2.ListSandboxesRequest + 231, // 326: agentcompose.v2.SandboxService.ListSandboxHistory:input_type -> agentcompose.v2.ListSandboxHistoryRequest + 235, // 327: agentcompose.v2.SandboxService.WatchSandbox:input_type -> agentcompose.v2.WatchSandboxRequest + 199, // 328: agentcompose.v2.DashboardService.GetDashboardOverview:input_type -> agentcompose.v2.GetDashboardOverviewRequest + 200, // 329: agentcompose.v2.DashboardService.WatchDashboardOverview:input_type -> agentcompose.v2.WatchDashboardOverviewRequest + 205, // 330: agentcompose.v2.SettingsService.GetGlobalEnv:input_type -> agentcompose.v2.GetGlobalEnvRequest + 207, // 331: agentcompose.v2.SettingsService.UpdateGlobalEnv:input_type -> agentcompose.v2.UpdateGlobalEnvRequest + 209, // 332: agentcompose.v2.SettingsService.GetCapabilityGatewayConfig:input_type -> agentcompose.v2.GetCapabilityGatewayConfigRequest + 212, // 333: agentcompose.v2.SettingsService.UpdateCapabilityGatewayConfig:input_type -> agentcompose.v2.UpdateCapabilityGatewayConfigRequest + 215, // 334: agentcompose.v2.SettingsService.ListWorkspacePresets:input_type -> agentcompose.v2.ListWorkspacePresetsRequest + 217, // 335: agentcompose.v2.SettingsService.CreateWorkspacePreset:input_type -> agentcompose.v2.CreateWorkspacePresetRequest + 218, // 336: agentcompose.v2.SettingsService.UpdateWorkspacePreset:input_type -> agentcompose.v2.UpdateWorkspacePresetRequest + 219, // 337: agentcompose.v2.SettingsService.DeleteWorkspacePreset:input_type -> agentcompose.v2.DeleteWorkspacePresetRequest + 222, // 338: agentcompose.v2.CapabilityService.GetCapabilityStatus:input_type -> agentcompose.v2.GetCapabilityStatusRequest + 224, // 339: agentcompose.v2.CapabilityService.ListCapabilitySets:input_type -> agentcompose.v2.ListCapabilitySetsRequest + 227, // 340: agentcompose.v2.CapabilityService.GetCapabilityCatalog:input_type -> agentcompose.v2.GetCapabilityCatalogRequest + 237, // 341: agentcompose.v2.LLMService.Generate:input_type -> agentcompose.v2.GenerateLLMRequest + 196, // 342: agentcompose.v2.ResourceService.ResolveID:input_type -> agentcompose.v2.ResolveResourceIDRequest + 240, // 343: agentcompose.v2.AuthService.WhoAmI:input_type -> agentcompose.v2.WhoAmIRequest + 243, // 344: agentcompose.v2.AuthService.ListRoles:input_type -> agentcompose.v2.ListRolesRequest + 245, // 345: agentcompose.v2.AuthService.ListTokens:input_type -> agentcompose.v2.ListTokensRequest + 247, // 346: agentcompose.v2.AuthService.CreateToken:input_type -> agentcompose.v2.CreateTokenRequest + 249, // 347: agentcompose.v2.AuthService.RevokeToken:input_type -> agentcompose.v2.RevokeTokenRequest + 252, // 348: agentcompose.v2.AuthService.ListOperationAudits:input_type -> agentcompose.v2.ListOperationAuditsRequest + 26, // 349: agentcompose.v2.ProjectService.ValidateProject:output_type -> agentcompose.v2.ValidateProjectResponse + 28, // 350: agentcompose.v2.ProjectService.ApplyProject:output_type -> agentcompose.v2.ApplyProjectResponse + 30, // 351: agentcompose.v2.ProjectService.GetProject:output_type -> agentcompose.v2.GetProjectResponse + 32, // 352: agentcompose.v2.ProjectService.ListProjects:output_type -> agentcompose.v2.ListProjectsResponse + 34, // 353: agentcompose.v2.ProjectService.RemoveProject:output_type -> agentcompose.v2.RemoveProjectResponse + 36, // 354: agentcompose.v2.ProjectService.WatchProject:output_type -> agentcompose.v2.WatchProjectResponse + 47, // 355: agentcompose.v2.ProjectService.GetScheduler:output_type -> agentcompose.v2.GetSchedulerResponse + 51, // 356: agentcompose.v2.ProjectService.ListSchedulers:output_type -> agentcompose.v2.ListSchedulersResponse + 54, // 357: agentcompose.v2.ProjectService.ListSchedulerEvents:output_type -> agentcompose.v2.ListSchedulerEventsResponse + 56, // 358: agentcompose.v2.ProjectService.RunScheduler:output_type -> agentcompose.v2.RunSchedulerResponse + 58, // 359: agentcompose.v2.ProjectService.StartSchedulerRun:output_type -> agentcompose.v2.StartSchedulerRunResponse + 60, // 360: agentcompose.v2.ProjectService.GetSchedulerRun:output_type -> agentcompose.v2.GetSchedulerRunResponse + 62, // 361: agentcompose.v2.ProjectService.ListSchedulerRuns:output_type -> agentcompose.v2.ListSchedulerRunsResponse + 64, // 362: agentcompose.v2.ProjectService.StopSchedulerRun:output_type -> agentcompose.v2.StopSchedulerRunResponse + 67, // 363: agentcompose.v2.ProjectService.SetSchedulerEnabled:output_type -> agentcompose.v2.SetSchedulerEnabledResponse + 69, // 364: agentcompose.v2.ProjectService.SetSchedulerTriggerEnabled:output_type -> agentcompose.v2.SetSchedulerTriggerEnabledResponse + 91, // 365: agentcompose.v2.RunService.RunAgent:output_type -> agentcompose.v2.RunAgentResponse + 194, // 366: agentcompose.v2.RunService.StartRun:output_type -> agentcompose.v2.StartRunResponse + 92, // 367: agentcompose.v2.RunService.RunAgentStream:output_type -> agentcompose.v2.RunAgentStreamResponse + 94, // 368: agentcompose.v2.RunService.RunAttach:output_type -> agentcompose.v2.RunAttachResponse + 98, // 369: agentcompose.v2.RunService.GetRun:output_type -> agentcompose.v2.GetRunResponse + 100, // 370: agentcompose.v2.RunService.ListRuns:output_type -> agentcompose.v2.ListRunsResponse + 102, // 371: agentcompose.v2.RunService.FollowRunLogs:output_type -> agentcompose.v2.RunLogChunk + 104, // 372: agentcompose.v2.RunService.StopRun:output_type -> agentcompose.v2.StopRunResponse + 107, // 373: agentcompose.v2.RunService.ListRunEvents:output_type -> agentcompose.v2.ListRunEventsResponse + 109, // 374: agentcompose.v2.RunService.ListSandboxRunEvents:output_type -> agentcompose.v2.ListSandboxRunEventsResponse + 134, // 375: agentcompose.v2.ExecService.Exec:output_type -> agentcompose.v2.ExecResponse + 135, // 376: agentcompose.v2.ExecService.ExecStream:output_type -> agentcompose.v2.ExecStreamResponse + 137, // 377: agentcompose.v2.ExecService.ExecAttach:output_type -> agentcompose.v2.ExecAttachResponse + 154, // 378: agentcompose.v2.ImageService.ListImages:output_type -> agentcompose.v2.ListImagesResponse + 156, // 379: agentcompose.v2.ImageService.PullImage:output_type -> agentcompose.v2.PullImageResponse + 158, // 380: agentcompose.v2.ImageService.InspectImage:output_type -> agentcompose.v2.InspectImageResponse + 160, // 381: agentcompose.v2.ImageService.RemoveImage:output_type -> agentcompose.v2.RemoveImageResponse + 162, // 382: agentcompose.v2.ImageService.BuildImage:output_type -> agentcompose.v2.BuildImageEvent + 165, // 383: agentcompose.v2.CacheService.ListCaches:output_type -> agentcompose.v2.ListCachesResponse + 167, // 384: agentcompose.v2.CacheService.InspectCache:output_type -> agentcompose.v2.InspectCacheResponse + 169, // 385: agentcompose.v2.CacheService.PruneCaches:output_type -> agentcompose.v2.PruneCachesResponse + 171, // 386: agentcompose.v2.CacheService.RemoveCache:output_type -> agentcompose.v2.RemoveCacheResponse + 175, // 387: agentcompose.v2.VolumeService.ListVolumes:output_type -> agentcompose.v2.ListVolumesResponse + 177, // 388: agentcompose.v2.VolumeService.CreateVolume:output_type -> agentcompose.v2.CreateVolumeResponse + 179, // 389: agentcompose.v2.VolumeService.InspectVolume:output_type -> agentcompose.v2.InspectVolumeResponse + 181, // 390: agentcompose.v2.VolumeService.RemoveVolume:output_type -> agentcompose.v2.RemoveVolumeResponse + 183, // 391: agentcompose.v2.VolumeService.PruneVolumes:output_type -> agentcompose.v2.PruneVolumesResponse + 111, // 392: agentcompose.v2.SandboxService.RemoveSandbox:output_type -> agentcompose.v2.RemoveSandboxResponse + 114, // 393: agentcompose.v2.SandboxService.PruneSandboxes:output_type -> agentcompose.v2.PruneSandboxesResponse + 116, // 394: agentcompose.v2.SandboxService.GetSandboxStats:output_type -> agentcompose.v2.GetSandboxStatsResponse + 122, // 395: agentcompose.v2.SandboxService.GetSandbox:output_type -> agentcompose.v2.GetSandboxResponse + 124, // 396: agentcompose.v2.SandboxService.StopSandbox:output_type -> agentcompose.v2.StopSandboxResponse + 126, // 397: agentcompose.v2.SandboxService.ResumeSandbox:output_type -> agentcompose.v2.ResumeSandboxResponse + 121, // 398: agentcompose.v2.SandboxService.ListSandboxes:output_type -> agentcompose.v2.ListSandboxesResponse + 234, // 399: agentcompose.v2.SandboxService.ListSandboxHistory:output_type -> agentcompose.v2.ListSandboxHistoryResponse + 236, // 400: agentcompose.v2.SandboxService.WatchSandbox:output_type -> agentcompose.v2.WatchSandboxResponse + 203, // 401: agentcompose.v2.DashboardService.GetDashboardOverview:output_type -> agentcompose.v2.GetDashboardOverviewResponse + 204, // 402: agentcompose.v2.DashboardService.WatchDashboardOverview:output_type -> agentcompose.v2.WatchDashboardOverviewResponse + 206, // 403: agentcompose.v2.SettingsService.GetGlobalEnv:output_type -> agentcompose.v2.GetGlobalEnvResponse + 208, // 404: agentcompose.v2.SettingsService.UpdateGlobalEnv:output_type -> agentcompose.v2.UpdateGlobalEnvResponse + 211, // 405: agentcompose.v2.SettingsService.GetCapabilityGatewayConfig:output_type -> agentcompose.v2.GetCapabilityGatewayConfigResponse + 213, // 406: agentcompose.v2.SettingsService.UpdateCapabilityGatewayConfig:output_type -> agentcompose.v2.UpdateCapabilityGatewayConfigResponse + 216, // 407: agentcompose.v2.SettingsService.ListWorkspacePresets:output_type -> agentcompose.v2.ListWorkspacePresetsResponse + 221, // 408: agentcompose.v2.SettingsService.CreateWorkspacePreset:output_type -> agentcompose.v2.WorkspacePresetResponse + 221, // 409: agentcompose.v2.SettingsService.UpdateWorkspacePreset:output_type -> agentcompose.v2.WorkspacePresetResponse + 220, // 410: agentcompose.v2.SettingsService.DeleteWorkspacePreset:output_type -> agentcompose.v2.DeleteWorkspacePresetResponse + 223, // 411: agentcompose.v2.CapabilityService.GetCapabilityStatus:output_type -> agentcompose.v2.CapabilityStatusResponse + 226, // 412: agentcompose.v2.CapabilityService.ListCapabilitySets:output_type -> agentcompose.v2.ListCapabilitySetsResponse + 230, // 413: agentcompose.v2.CapabilityService.GetCapabilityCatalog:output_type -> agentcompose.v2.GetCapabilityCatalogResponse + 238, // 414: agentcompose.v2.LLMService.Generate:output_type -> agentcompose.v2.GenerateLLMResponse + 197, // 415: agentcompose.v2.ResourceService.ResolveID:output_type -> agentcompose.v2.ResolveResourceIDResponse + 241, // 416: agentcompose.v2.AuthService.WhoAmI:output_type -> agentcompose.v2.WhoAmIResponse + 244, // 417: agentcompose.v2.AuthService.ListRoles:output_type -> agentcompose.v2.ListRolesResponse + 246, // 418: agentcompose.v2.AuthService.ListTokens:output_type -> agentcompose.v2.ListTokensResponse + 248, // 419: agentcompose.v2.AuthService.CreateToken:output_type -> agentcompose.v2.CreateTokenResponse + 250, // 420: agentcompose.v2.AuthService.RevokeToken:output_type -> agentcompose.v2.RevokeTokenResponse + 253, // 421: agentcompose.v2.AuthService.ListOperationAudits:output_type -> agentcompose.v2.ListOperationAuditsResponse + 349, // [349:422] is the sub-list for method output_type + 276, // [276:349] is the sub-list for method input_type + 276, // [276:276] is the sub-list for extension type_name + 276, // [276:276] is the sub-list for extension extendee + 0, // [0:276] is the sub-list for field type_name } func init() { file_agentcompose_v2_agentcompose_proto_init() } @@ -18745,9 +19948,9 @@ func file_agentcompose_v2_agentcompose_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_agentcompose_v2_agentcompose_proto_rawDesc), len(file_agentcompose_v2_agentcompose_proto_rawDesc)), NumEnums: 25, - NumMessages: 226, + NumMessages: 241, NumExtensions: 0, - NumServices: 12, + NumServices: 13, }, GoTypes: file_agentcompose_v2_agentcompose_proto_goTypes, DependencyIndexes: file_agentcompose_v2_agentcompose_proto_depIdxs, diff --git a/proto/agentcompose/v2/agentcompose.proto b/proto/agentcompose/v2/agentcompose.proto index bc5003798..6718e15af 100644 --- a/proto/agentcompose/v2/agentcompose.proto +++ b/proto/agentcompose/v2/agentcompose.proto @@ -115,6 +115,15 @@ service ResourceService { rpc ResolveID(ResolveResourceIDRequest) returns (ResolveResourceIDResponse); } +service AuthService { + rpc WhoAmI(WhoAmIRequest) returns (WhoAmIResponse); + rpc ListRoles(ListRolesRequest) returns (ListRolesResponse); + rpc ListTokens(ListTokensRequest) returns (ListTokensResponse); + rpc CreateToken(CreateTokenRequest) returns (CreateTokenResponse); + rpc RevokeToken(RevokeTokenRequest) returns (RevokeTokenResponse); + rpc ListOperationAudits(ListOperationAuditsRequest) returns (ListOperationAuditsResponse); +} + enum ProjectValidationSeverity { PROJECT_VALIDATION_SEVERITY_UNSPECIFIED = 0; PROJECT_VALIDATION_SEVERITY_WARNING = 1; @@ -1817,3 +1826,108 @@ message GenerateLLMResponse { string finish_reason = 4; string json = 5; } + +message AuthToken { + string id = 1; + string name = 2; + string description = 3; + string role = 4; + string origin = 5; + reserved 6; + reserved "fingerprint"; + string created_by_token_id = 7; + google.protobuf.Timestamp created_at = 8; + string revoked_by_token_id = 9; + google.protobuf.Timestamp revoked_at = 10; +} + +message WhoAmIRequest {} + +message WhoAmIResponse { + AuthToken token = 1; + string role = 2; + string origin = 3; + bool authentication_initialized = 4; +} + +message AuthRole { + string name = 1; + string description = 2; + bool read_only = 3; + bool builtin = 4; +} + +message ListRolesRequest {} +message ListRolesResponse { repeated AuthRole roles = 1; } + +message ListTokensRequest { + bool include_revoked = 1; + uint32 limit = 2; + string cursor = 3; +} + +message ListTokensResponse { + repeated AuthToken tokens = 1; + string next_cursor = 2; +} + +message CreateTokenRequest { + string name = 1; + string description = 2; + string role = 3; + string token = 4; + string client_request_id = 5; +} + +message CreateTokenResponse { + AuthToken item = 1; + bool created = 2; + bool idempotent_replay = 3; +} + +message RevokeTokenRequest { string token = 1; } + +message RevokeTokenResponse { + AuthToken item = 1; + bool revoked = 2; + bool already_revoked = 3; +} + +message OperationAudit { + string id = 1; + uint64 sequence = 2; + string request_id = 3; + string token_id = 4; + string token_name = 5; + string role = 6; + string origin = 7; + string action = 8; + string resource_type = 9; + string resource_id = 10; + string project_id = 11; + string status = 12; + string params_json = 13; + string changes_json = 14; + string error_code = 15; + string error_message = 16; + google.protobuf.Timestamp started_at = 17; + google.protobuf.Timestamp completed_at = 18; +} + +message ListOperationAuditsRequest { + string token = 1; + string action = 2; + string resource_type = 3; + string resource_id = 4; + string project_id = 5; + string status = 6; + google.protobuf.Timestamp started_after = 7; + google.protobuf.Timestamp started_before = 8; + uint32 limit = 9; + string cursor = 10; +} + +message ListOperationAuditsResponse { + repeated OperationAudit audits = 1; + string next_cursor = 2; +} diff --git a/proto/agentcompose/v2/agentcomposev2connect/agentcompose.connect.go b/proto/agentcompose/v2/agentcomposev2connect/agentcompose.connect.go index 446d263ff..fbc120645 100644 --- a/proto/agentcompose/v2/agentcomposev2connect/agentcompose.connect.go +++ b/proto/agentcompose/v2/agentcomposev2connect/agentcompose.connect.go @@ -45,6 +45,8 @@ const ( LLMServiceName = "agentcompose.v2.LLMService" // ResourceServiceName is the fully-qualified name of the ResourceService service. ResourceServiceName = "agentcompose.v2.ResourceService" + // AuthServiceName is the fully-qualified name of the AuthService service. + AuthServiceName = "agentcompose.v2.AuthService" ) // These constants are the fully-qualified names of the RPCs defined in this package. They're @@ -242,6 +244,19 @@ const ( // ResourceServiceResolveIDProcedure is the fully-qualified name of the ResourceService's ResolveID // RPC. ResourceServiceResolveIDProcedure = "/agentcompose.v2.ResourceService/ResolveID" + // AuthServiceWhoAmIProcedure is the fully-qualified name of the AuthService's WhoAmI RPC. + AuthServiceWhoAmIProcedure = "/agentcompose.v2.AuthService/WhoAmI" + // AuthServiceListRolesProcedure is the fully-qualified name of the AuthService's ListRoles RPC. + AuthServiceListRolesProcedure = "/agentcompose.v2.AuthService/ListRoles" + // AuthServiceListTokensProcedure is the fully-qualified name of the AuthService's ListTokens RPC. + AuthServiceListTokensProcedure = "/agentcompose.v2.AuthService/ListTokens" + // AuthServiceCreateTokenProcedure is the fully-qualified name of the AuthService's CreateToken RPC. + AuthServiceCreateTokenProcedure = "/agentcompose.v2.AuthService/CreateToken" + // AuthServiceRevokeTokenProcedure is the fully-qualified name of the AuthService's RevokeToken RPC. + AuthServiceRevokeTokenProcedure = "/agentcompose.v2.AuthService/RevokeToken" + // AuthServiceListOperationAuditsProcedure is the fully-qualified name of the AuthService's + // ListOperationAudits RPC. + AuthServiceListOperationAuditsProcedure = "/agentcompose.v2.AuthService/ListOperationAudits" ) // ProjectServiceClient is a client for the agentcompose.v2.ProjectService service. @@ -2526,3 +2541,203 @@ type UnimplementedResourceServiceHandler struct{} func (UnimplementedResourceServiceHandler) ResolveID(context.Context, *connect.Request[v2.ResolveResourceIDRequest]) (*connect.Response[v2.ResolveResourceIDResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("agentcompose.v2.ResourceService.ResolveID is not implemented")) } + +// AuthServiceClient is a client for the agentcompose.v2.AuthService service. +type AuthServiceClient interface { + WhoAmI(context.Context, *connect.Request[v2.WhoAmIRequest]) (*connect.Response[v2.WhoAmIResponse], error) + ListRoles(context.Context, *connect.Request[v2.ListRolesRequest]) (*connect.Response[v2.ListRolesResponse], error) + ListTokens(context.Context, *connect.Request[v2.ListTokensRequest]) (*connect.Response[v2.ListTokensResponse], error) + CreateToken(context.Context, *connect.Request[v2.CreateTokenRequest]) (*connect.Response[v2.CreateTokenResponse], error) + RevokeToken(context.Context, *connect.Request[v2.RevokeTokenRequest]) (*connect.Response[v2.RevokeTokenResponse], error) + ListOperationAudits(context.Context, *connect.Request[v2.ListOperationAuditsRequest]) (*connect.Response[v2.ListOperationAuditsResponse], error) +} + +// NewAuthServiceClient constructs a client for the agentcompose.v2.AuthService service. By default, +// it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and +// sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() +// or connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewAuthServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) AuthServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + authServiceMethods := v2.File_agentcompose_v2_agentcompose_proto.Services().ByName("AuthService").Methods() + return &authServiceClient{ + whoAmI: connect.NewClient[v2.WhoAmIRequest, v2.WhoAmIResponse]( + httpClient, + baseURL+AuthServiceWhoAmIProcedure, + connect.WithSchema(authServiceMethods.ByName("WhoAmI")), + connect.WithClientOptions(opts...), + ), + listRoles: connect.NewClient[v2.ListRolesRequest, v2.ListRolesResponse]( + httpClient, + baseURL+AuthServiceListRolesProcedure, + connect.WithSchema(authServiceMethods.ByName("ListRoles")), + connect.WithClientOptions(opts...), + ), + listTokens: connect.NewClient[v2.ListTokensRequest, v2.ListTokensResponse]( + httpClient, + baseURL+AuthServiceListTokensProcedure, + connect.WithSchema(authServiceMethods.ByName("ListTokens")), + connect.WithClientOptions(opts...), + ), + createToken: connect.NewClient[v2.CreateTokenRequest, v2.CreateTokenResponse]( + httpClient, + baseURL+AuthServiceCreateTokenProcedure, + connect.WithSchema(authServiceMethods.ByName("CreateToken")), + connect.WithClientOptions(opts...), + ), + revokeToken: connect.NewClient[v2.RevokeTokenRequest, v2.RevokeTokenResponse]( + httpClient, + baseURL+AuthServiceRevokeTokenProcedure, + connect.WithSchema(authServiceMethods.ByName("RevokeToken")), + connect.WithClientOptions(opts...), + ), + listOperationAudits: connect.NewClient[v2.ListOperationAuditsRequest, v2.ListOperationAuditsResponse]( + httpClient, + baseURL+AuthServiceListOperationAuditsProcedure, + connect.WithSchema(authServiceMethods.ByName("ListOperationAudits")), + connect.WithClientOptions(opts...), + ), + } +} + +// authServiceClient implements AuthServiceClient. +type authServiceClient struct { + whoAmI *connect.Client[v2.WhoAmIRequest, v2.WhoAmIResponse] + listRoles *connect.Client[v2.ListRolesRequest, v2.ListRolesResponse] + listTokens *connect.Client[v2.ListTokensRequest, v2.ListTokensResponse] + createToken *connect.Client[v2.CreateTokenRequest, v2.CreateTokenResponse] + revokeToken *connect.Client[v2.RevokeTokenRequest, v2.RevokeTokenResponse] + listOperationAudits *connect.Client[v2.ListOperationAuditsRequest, v2.ListOperationAuditsResponse] +} + +// WhoAmI calls agentcompose.v2.AuthService.WhoAmI. +func (c *authServiceClient) WhoAmI(ctx context.Context, req *connect.Request[v2.WhoAmIRequest]) (*connect.Response[v2.WhoAmIResponse], error) { + return c.whoAmI.CallUnary(ctx, req) +} + +// ListRoles calls agentcompose.v2.AuthService.ListRoles. +func (c *authServiceClient) ListRoles(ctx context.Context, req *connect.Request[v2.ListRolesRequest]) (*connect.Response[v2.ListRolesResponse], error) { + return c.listRoles.CallUnary(ctx, req) +} + +// ListTokens calls agentcompose.v2.AuthService.ListTokens. +func (c *authServiceClient) ListTokens(ctx context.Context, req *connect.Request[v2.ListTokensRequest]) (*connect.Response[v2.ListTokensResponse], error) { + return c.listTokens.CallUnary(ctx, req) +} + +// CreateToken calls agentcompose.v2.AuthService.CreateToken. +func (c *authServiceClient) CreateToken(ctx context.Context, req *connect.Request[v2.CreateTokenRequest]) (*connect.Response[v2.CreateTokenResponse], error) { + return c.createToken.CallUnary(ctx, req) +} + +// RevokeToken calls agentcompose.v2.AuthService.RevokeToken. +func (c *authServiceClient) RevokeToken(ctx context.Context, req *connect.Request[v2.RevokeTokenRequest]) (*connect.Response[v2.RevokeTokenResponse], error) { + return c.revokeToken.CallUnary(ctx, req) +} + +// ListOperationAudits calls agentcompose.v2.AuthService.ListOperationAudits. +func (c *authServiceClient) ListOperationAudits(ctx context.Context, req *connect.Request[v2.ListOperationAuditsRequest]) (*connect.Response[v2.ListOperationAuditsResponse], error) { + return c.listOperationAudits.CallUnary(ctx, req) +} + +// AuthServiceHandler is an implementation of the agentcompose.v2.AuthService service. +type AuthServiceHandler interface { + WhoAmI(context.Context, *connect.Request[v2.WhoAmIRequest]) (*connect.Response[v2.WhoAmIResponse], error) + ListRoles(context.Context, *connect.Request[v2.ListRolesRequest]) (*connect.Response[v2.ListRolesResponse], error) + ListTokens(context.Context, *connect.Request[v2.ListTokensRequest]) (*connect.Response[v2.ListTokensResponse], error) + CreateToken(context.Context, *connect.Request[v2.CreateTokenRequest]) (*connect.Response[v2.CreateTokenResponse], error) + RevokeToken(context.Context, *connect.Request[v2.RevokeTokenRequest]) (*connect.Response[v2.RevokeTokenResponse], error) + ListOperationAudits(context.Context, *connect.Request[v2.ListOperationAuditsRequest]) (*connect.Response[v2.ListOperationAuditsResponse], error) +} + +// NewAuthServiceHandler builds an HTTP handler from the service implementation. It returns the path +// on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewAuthServiceHandler(svc AuthServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + authServiceMethods := v2.File_agentcompose_v2_agentcompose_proto.Services().ByName("AuthService").Methods() + authServiceWhoAmIHandler := connect.NewUnaryHandler( + AuthServiceWhoAmIProcedure, + svc.WhoAmI, + connect.WithSchema(authServiceMethods.ByName("WhoAmI")), + connect.WithHandlerOptions(opts...), + ) + authServiceListRolesHandler := connect.NewUnaryHandler( + AuthServiceListRolesProcedure, + svc.ListRoles, + connect.WithSchema(authServiceMethods.ByName("ListRoles")), + connect.WithHandlerOptions(opts...), + ) + authServiceListTokensHandler := connect.NewUnaryHandler( + AuthServiceListTokensProcedure, + svc.ListTokens, + connect.WithSchema(authServiceMethods.ByName("ListTokens")), + connect.WithHandlerOptions(opts...), + ) + authServiceCreateTokenHandler := connect.NewUnaryHandler( + AuthServiceCreateTokenProcedure, + svc.CreateToken, + connect.WithSchema(authServiceMethods.ByName("CreateToken")), + connect.WithHandlerOptions(opts...), + ) + authServiceRevokeTokenHandler := connect.NewUnaryHandler( + AuthServiceRevokeTokenProcedure, + svc.RevokeToken, + connect.WithSchema(authServiceMethods.ByName("RevokeToken")), + connect.WithHandlerOptions(opts...), + ) + authServiceListOperationAuditsHandler := connect.NewUnaryHandler( + AuthServiceListOperationAuditsProcedure, + svc.ListOperationAudits, + connect.WithSchema(authServiceMethods.ByName("ListOperationAudits")), + connect.WithHandlerOptions(opts...), + ) + return "/agentcompose.v2.AuthService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case AuthServiceWhoAmIProcedure: + authServiceWhoAmIHandler.ServeHTTP(w, r) + case AuthServiceListRolesProcedure: + authServiceListRolesHandler.ServeHTTP(w, r) + case AuthServiceListTokensProcedure: + authServiceListTokensHandler.ServeHTTP(w, r) + case AuthServiceCreateTokenProcedure: + authServiceCreateTokenHandler.ServeHTTP(w, r) + case AuthServiceRevokeTokenProcedure: + authServiceRevokeTokenHandler.ServeHTTP(w, r) + case AuthServiceListOperationAuditsProcedure: + authServiceListOperationAuditsHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedAuthServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedAuthServiceHandler struct{} + +func (UnimplementedAuthServiceHandler) WhoAmI(context.Context, *connect.Request[v2.WhoAmIRequest]) (*connect.Response[v2.WhoAmIResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("agentcompose.v2.AuthService.WhoAmI is not implemented")) +} + +func (UnimplementedAuthServiceHandler) ListRoles(context.Context, *connect.Request[v2.ListRolesRequest]) (*connect.Response[v2.ListRolesResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("agentcompose.v2.AuthService.ListRoles is not implemented")) +} + +func (UnimplementedAuthServiceHandler) ListTokens(context.Context, *connect.Request[v2.ListTokensRequest]) (*connect.Response[v2.ListTokensResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("agentcompose.v2.AuthService.ListTokens is not implemented")) +} + +func (UnimplementedAuthServiceHandler) CreateToken(context.Context, *connect.Request[v2.CreateTokenRequest]) (*connect.Response[v2.CreateTokenResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("agentcompose.v2.AuthService.CreateToken is not implemented")) +} + +func (UnimplementedAuthServiceHandler) RevokeToken(context.Context, *connect.Request[v2.RevokeTokenRequest]) (*connect.Response[v2.RevokeTokenResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("agentcompose.v2.AuthService.RevokeToken is not implemented")) +} + +func (UnimplementedAuthServiceHandler) ListOperationAudits(context.Context, *connect.Request[v2.ListOperationAuditsRequest]) (*connect.Response[v2.ListOperationAuditsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("agentcompose.v2.AuthService.ListOperationAudits is not implemented")) +} diff --git a/scripts/test-agent-compose-rbac.sh b/scripts/test-agent-compose-rbac.sh new file mode 100755 index 000000000..7b37528dc --- /dev/null +++ b/scripts/test-agent-compose-rbac.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +usage: test-agent-compose-rbac.sh --binary PATH + +Start an isolated local daemon over HTTP and verify the CLI RBAC, multi-token, +revocation, audit, and environment-token rotation workflows. Docker and KVM are +not required. +EOF +} + +fail() { + printf 'test-agent-compose-rbac: %s\n' "$*" >&2 + exit 1 +} + +binary='' +while [[ $# -gt 0 ]]; do + case $1 in + --binary) + [[ $# -ge 2 ]] || fail '--binary requires a value' + binary=$2 + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "unknown argument: $1" + ;; + esac +done + +[[ -n $binary ]] || fail '--binary is required' +for command_name in curl jq mktemp python3; do + command -v "$command_name" >/dev/null 2>&1 || fail "$command_name is required" +done + +case $binary in + /*) ;; + *) binary=$PWD/$binary ;; +esac +binary_dir=$(cd -P "$(dirname "$binary")" && pwd) +binary=$binary_dir/$(basename "$binary") +[[ -f $binary && -x $binary ]] || fail "binary is not executable: $binary" + +test_root=$(mktemp -d "${TMPDIR:-/tmp}/ac-rbac.XXXXXX") +data_root=$test_root/data +work_dir=$test_root/work +runtime_root=$test_root/runtime +home_root=$test_root/home +socket_path=$test_root/agent-compose.sock +daemon_stdout=$test_root/daemon.stdout +daemon_stderr=$test_root/daemon.stderr +admin_config=$test_root/admin-config.yml +readonly_config=$test_root/readonly-config.yml +revoked_stderr=$test_root/revoked.stderr +denied_stderr=$test_root/denied.stderr +daemon_pid='' + +bootstrap_token='ac_bootstrap_admin_token_0123456789abcdef' +rotated_bootstrap_token='ac_rotated_admin_token_0123456789abcdef' +active_bootstrap_token=$bootstrap_token +http_port=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()') +host=http://127.0.0.1:$http_port + +new_token() { + python3 -c 'import secrets; print("ac_" + secrets.token_urlsafe(32))' +} + +new_request_id() { + python3 -c 'import uuid; print(uuid.uuid4())' +} + +print_diagnostic_file() { + local label=$1 file=$2 + if [[ -s $file ]]; then + printf '\n--- %s ---\n' "$label" >&2 + sed 's/^/ /' "$file" >&2 + fi +} + +stop_daemon() { + local pid attempt=0 forced=0 status=0 + [[ -n $daemon_pid ]] || return 0 + pid=$daemon_pid + if kill -0 "$pid" 2>/dev/null; then + kill -TERM "$pid" 2>/dev/null || true + while kill -0 "$pid" 2>/dev/null && [[ $attempt -lt 100 ]]; do + sleep 0.1 + attempt=$((attempt + 1)) + done + if kill -0 "$pid" 2>/dev/null; then + forced=1 + kill -KILL "$pid" 2>/dev/null || true + fi + fi + if wait "$pid"; then + status=0 + else + status=$? + fi + daemon_pid='' + [[ $forced -eq 0 && $status -eq 0 ]] +} + +cleanup() { + cleanup_status=$? + trap - EXIT HUP INT TERM + stop_daemon || true + if [[ $cleanup_status -ne 0 ]]; then + print_diagnostic_file 'daemon stdout' "$daemon_stdout" + print_diagnostic_file 'daemon stderr' "$daemon_stderr" + print_diagnostic_file 'read-only denied stderr' "$denied_stderr" + print_diagnostic_file 'revoked token stderr' "$revoked_stderr" + fi + rm -rf "$test_root" + exit "$cleanup_status" +} + +interrupted() { + printf 'test-agent-compose-rbac: interrupted\n' >&2 + exit 130 +} + +trap cleanup EXIT +trap interrupted HUP INT TERM + +start_daemon() { + local token=$1 ready=0 attempt=0 + : >"$daemon_stdout" + : >"$daemon_stderr" + ( + cd "$work_dir" + unset AGENT_COMPOSE_HOST AGENT_COMPOSE_CONFIG + export AGENT_COMPOSE_AUTH_TOKEN="$token" + export AGENT_COMPOSE_SOCKET="$socket_path" + export DATA_ROOT="$data_root" + export DOCKER_HOST="unix://$test_root/docker-unavailable.sock" + export HOME="$home_root" + export HTTP_LISTEN="127.0.0.1:$http_port" + export RUNTIME_DRIVER=docker + export SANDBOX_ROOT="$data_root/sandboxes" + export XDG_RUNTIME_DIR="$runtime_root" + exec "$binary" daemon + ) >"$daemon_stdout" 2>"$daemon_stderr" & + daemon_pid=$! + + while [[ $attempt -lt 300 ]]; do + if ! kill -0 "$daemon_pid" 2>/dev/null; then + break + fi + if curl --fail --silent --show-error --max-time 1 \ + --header "Authorization: Bearer $token" \ + --output /dev/null "$host/api/version" 2>/dev/null; then + ready=1 + break + fi + sleep 0.1 + attempt=$((attempt + 1)) + done + [[ $ready -eq 1 ]] || fail 'daemon did not become ready within 30 seconds' +} + +mkdir -p "$data_root" "$work_dir" "$runtime_root" "$home_root" +start_daemon "$active_bootstrap_token" + +AGENT_COMPOSE_CONFIG=$admin_config "$binary" --host "$host" \ + auth login --token "$active_bootstrap_token" >/dev/null + +admin_identity=$(AGENT_COMPOSE_CONFIG=$admin_config "$binary" --host "$host" --json auth whoami) +jq -e '.role == "admin" and .token.name == "default-admin" and .authenticationInitialized == true' \ + >/dev/null <<<"$admin_identity" || fail 'bootstrap token is not the initialized default admin' + +readonly_create=$(AGENT_COMPOSE_CONFIG=$admin_config "$binary" --host "$host" --json \ + auth token create --name readonly-ci --role read-only-admin --description 'read-only CI') +readonly_secret=$(jq -r '.token' <<<"$readonly_create") +readonly_id=$(jq -r '.item.id' <<<"$readonly_create") +[[ $readonly_secret == ac_* && -n $readonly_id ]] || fail 'CLI did not return its locally generated token and item metadata' + +AGENT_COMPOSE_CONFIG=$readonly_config "$binary" --host "$host" \ + auth login --token "$readonly_secret" >/dev/null +readonly_identity=$(AGENT_COMPOSE_CONFIG=$readonly_config "$binary" --host "$host" --json auth whoami) +jq -e --arg id "$readonly_id" '.role == "read-only-admin" and .token.id == $id' \ + >/dev/null <<<"$readonly_identity" || fail 'read-only token identity is incorrect' + +audits_before=$(AGENT_COMPOSE_CONFIG=$readonly_config "$binary" --host "$host" --json \ + audit ls --token "$readonly_id") +before_count=$(jq '.audits | length' <<<"$audits_before") +AGENT_COMPOSE_CONFIG=$readonly_config "$binary" --host "$host" status >/dev/null +AGENT_COMPOSE_CONFIG=$readonly_config "$binary" --host "$host" --json auth token ls >/dev/null +audits_after_queries=$(AGENT_COMPOSE_CONFIG=$readonly_config "$binary" --host "$host" --json \ + audit ls --token "$readonly_id") +after_query_count=$(jq '.audits | length' <<<"$audits_after_queries") +[[ $after_query_count -eq $before_count ]] || fail 'pure queries unexpectedly created audit rows' + +if AGENT_COMPOSE_CONFIG=$readonly_config "$binary" --host "$host" \ + auth token create --name forbidden --role read-only-admin > /dev/null 2>"$denied_stderr"; then + fail 'read-only admin created a token' +fi + +denied_audits=$(AGENT_COMPOSE_CONFIG=$readonly_config "$binary" --host "$host" --json \ + audit ls --token "$readonly_id" --action auth.token.create --status denied) +jq -e --arg id "$readonly_id" \ + '.audits | any(.tokenId == $id and .action == "auth.token.create" and .status == "denied")' \ + >/dev/null <<<"$denied_audits" || fail 'denied token creation was not audited' + +api_token=$(new_token) +api_request_id=$(new_request_id) +api_request=$(jq -cn \ + --arg name api-client \ + --arg description 'created through Connect JSON' \ + --arg role admin \ + --arg token "$api_token" \ + --arg request_id "$api_request_id" \ + '{name:$name, description:$description, role:$role, token:$token, clientRequestId:$request_id}') +api_response=$(curl --fail --silent --show-error \ + --header 'Connect-Protocol-Version: 1' \ + --header 'Content-Type: application/json' \ + --header "Authorization: Bearer $active_bootstrap_token" \ + --data "$api_request" \ + "$host/agentcompose.v2.AuthService/CreateToken") +jq -e '.created == true and (.item.id | length > 0) and (has("token") | not)' \ + >/dev/null <<<"$api_response" || fail 'CreateToken API response shape is invalid or contains a token field' +if [[ $api_response == *"$api_token"* ]]; then + fail 'CreateToken API response leaked the plaintext token' +fi + +api_retry_response=$(curl --fail --silent --show-error \ + --header 'Connect-Protocol-Version: 1' \ + --header 'Content-Type: application/json' \ + --header "Authorization: Bearer $active_bootstrap_token" \ + --data "$api_request" \ + "$host/agentcompose.v2.AuthService/CreateToken") +jq -e --arg id "$(jq -r '.item.id' <<<"$api_response")" \ + '(.created // false) == false and .idempotentReplay == true and .item.id == $id and (has("token") | not)' \ + >/dev/null <<<"$api_retry_response" || fail 'CreateToken idempotent replay did not return the same metadata' + +token_list=$(AGENT_COMPOSE_CONFIG=$admin_config "$binary" --host "$host" --json auth token ls) +audit_list=$(AGENT_COMPOSE_CONFIG=$admin_config "$binary" --host "$host" --json audit ls) +[[ $token_list != *"$readonly_secret"* && $token_list != *"$api_token"* ]] \ + || fail 'token list leaked a plaintext token' +[[ $audit_list != *"$readonly_secret"* && $audit_list != *"$api_token"* ]] \ + || fail 'audit list leaked a plaintext token' + +AGENT_COMPOSE_CONFIG=$admin_config "$binary" --host "$host" \ + auth token revoke "$readonly_id" >/dev/null +if AGENT_COMPOSE_CONFIG=$readonly_config "$binary" --host "$host" status \ + >/dev/null 2>"$revoked_stderr"; then + fail 'revoked token still authenticated' +fi + +if ! stop_daemon; then + fail 'daemon did not stop cleanly before environment-token rotation' +fi +active_bootstrap_token=$rotated_bootstrap_token +start_daemon "$active_bootstrap_token" + +if AGENT_COMPOSE_CONFIG=$admin_config "$binary" --host "$host" status >/dev/null 2>&1; then + fail 'old environment token still authenticated after rotation' +fi +AGENT_COMPOSE_CONFIG=$admin_config "$binary" --host "$host" \ + auth login --token "$active_bootstrap_token" >/dev/null +rotated_identity=$(AGENT_COMPOSE_CONFIG=$admin_config "$binary" --host "$host" --json auth whoami) +jq -e '.role == "admin" and .token.name == "default-admin"' \ + >/dev/null <<<"$rotated_identity" || fail 'rotated environment token is not the default admin' + +if ! stop_daemon; then + fail 'daemon did not stop cleanly' +fi + +printf 'agent-compose CLI RBAC, multi-token, revocation, and audit E2E passed\n'