diff --git a/Makefile b/Makefile index d73f597..69feaca 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,14 @@ VERSION ?= dev LDFLAGS := -ldflags "-X github.com/cordon-co/cordon-cli/cli/cmd.Version=$(VERSION)" BUILD := build +OPENAPI_SPEC ?= ../openapi/cordon-v1.openapi.yaml +OPENAPI_CONFIG := cli/internal/apicontract/oapi-codegen.yaml +OPENAPI_OUTPUT := cli/internal/apicontract/gen_types.go .PHONY: build build-all clean fmt vet \ build-darwin-arm64 build-darwin-amd64 \ - build-linux-amd64 build-linux-arm64 + build-linux-amd64 build-linux-arm64 \ + openapi-generate openapi-check ## build: compile for the current platform build: @@ -36,3 +40,15 @@ vet: ## clean: remove build artifacts clean: rm -rf $(BUILD) + +## openapi-generate: generate API contract types from OpenAPI spec +openapi-generate: + @test -f "$(OPENAPI_SPEC)" || (echo "missing OpenAPI spec at $(OPENAPI_SPEC)"; exit 1) + go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.4.1 \ + -config $(OPENAPI_CONFIG) \ + $(OPENAPI_SPEC) + +## openapi-check: verify generated contract types are up to date +openapi-check: openapi-generate + @git diff --exit-code -- $(OPENAPI_OUTPUT) >/dev/null || \ + (echo "OpenAPI generated file is out of date: $(OPENAPI_OUTPUT)"; git --no-pager diff -- $(OPENAPI_OUTPUT); exit 1) diff --git a/cli/cmd/auth/login.go b/cli/cmd/auth/login.go index 2297c91..bc3295b 100644 --- a/cli/cmd/auth/login.go +++ b/cli/cmd/auth/login.go @@ -9,6 +9,7 @@ import ( "time" "github.com/cordon-co/cordon-cli/cli/internal/api" + "github.com/cordon-co/cordon-cli/cli/internal/apicontract" "github.com/cordon-co/cordon-cli/cli/internal/flags" "github.com/spf13/cobra" ) @@ -21,27 +22,8 @@ var loginCmd = &cobra.Command{ RunE: RunLogin, } -// deviceResponse is the response from POST /api/v1/auth/device. -type deviceResponse struct { - DeviceCode string `json:"device_code"` - UserCode string `json:"user_code"` - VerificationURI string `json:"verification_uri"` - ExpiresIn int `json:"expires_in"` - Interval int `json:"interval"` -} - -// tokenResponse is the success response from POST /api/v1/auth/token. -type tokenResponse struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` - User api.User `json:"user"` -} - -// tokenErrorResponse is the error response from POST /api/v1/auth/token. -type tokenErrorResponse struct { - Error string `json:"error"` -} +type deviceResponse = apicontract.DeviceCodeResponse +type tokenResponse = apicontract.TokenResponse type loginResult struct { User api.User `json:"user"` @@ -78,11 +60,11 @@ func RunLogin(cmd *cobra.Command, args []string) error { // Step 2: Display code and open browser. if !flags.JSON { - fmt.Fprintf(cmd.OutOrStdout(), "\nOpen this URL in your browser: %s\n", device.VerificationURI) + fmt.Fprintf(cmd.OutOrStdout(), "\nOpen this URL in your browser: %s\n", device.VerificationUri) fmt.Fprintf(cmd.OutOrStdout(), "Enter code: %s\n\n", device.UserCode) fmt.Fprintln(cmd.OutOrStdout(), "Waiting for authorization...") } - openBrowser(device.VerificationURI) + openBrowser(device.VerificationUri) // Step 3: Poll for token. interval := time.Duration(device.Interval) * time.Second @@ -119,9 +101,14 @@ func RunLogin(cmd *cobra.Command, args []string) error { // Success — save credentials. now := time.Now().UTC() + user := api.User{ + ID: token.User.Id, + Username: token.User.Username, + DisplayName: token.User.DisplayName, + } creds := &api.Credentials{ AccessToken: token.AccessToken, - User: token.User, + User: user, IssuedAt: now, ExpiresAt: now.Add(time.Duration(token.ExpiresIn) * time.Second), } diff --git a/cli/cmd/auth/status.go b/cli/cmd/auth/status.go index e3ec007..0abb9a9 100644 --- a/cli/cmd/auth/status.go +++ b/cli/cmd/auth/status.go @@ -7,6 +7,7 @@ import ( "time" "github.com/cordon-co/cordon-cli/cli/internal/api" + "github.com/cordon-co/cordon-cli/cli/internal/apicontract" "github.com/cordon-co/cordon-cli/cli/internal/flags" "github.com/spf13/cobra" ) @@ -19,12 +20,6 @@ var statusCmd = &cobra.Command{ RunE: runStatus, } -// meResponse is the response from GET /api/v1/auth/me. -type meResponse struct { - User api.User `json:"user"` - Perimeters []perimeter `json:"perimeters"` -} - type perimeter struct { ID string `json:"id"` Name string `json:"name"` @@ -55,7 +50,7 @@ func runStatus(cmd *cobra.Command, args []string) error { // Verify token with server. client := api.NewClientWithToken(creds.AccessToken) - var me meResponse + var me apicontract.MeResponse _, err = client.GetJSON("/api/v1/auth/me", &me) if err != nil { if errors.Is(err, api.ErrUnauthorized) { @@ -72,26 +67,40 @@ func runStatus(cmd *cobra.Command, args []string) error { return fmt.Errorf("auth status: verify token: %w", err) } + user := api.User{ + ID: me.User.Id, + Username: me.User.Username, + DisplayName: me.User.DisplayName, + } + perimeters := make([]perimeter, 0, len(me.Perimeters)) + for _, p := range me.Perimeters { + perimeters = append(perimeters, perimeter{ + ID: p.Id, + Name: p.Name, + Role: string(p.Role), + }) + } + if flags.JSON { out, _ := json.MarshalIndent(statusResult{ Authenticated: true, - User: &me.User, - Perimeters: me.Perimeters, + User: &user, + Perimeters: perimeters, ExpiresAt: &creds.ExpiresAt, }, "", " ") fmt.Println(string(out)) return nil } - fmt.Fprintf(cmd.OutOrStdout(), "Logged in as %s", me.User.Username) - if me.User.DisplayName != "" && me.User.DisplayName != me.User.Username { - fmt.Fprintf(cmd.OutOrStdout(), " (%s)", me.User.DisplayName) + fmt.Fprintf(cmd.OutOrStdout(), "Logged in as %s", user.Username) + if user.DisplayName != "" && user.DisplayName != user.Username { + fmt.Fprintf(cmd.OutOrStdout(), " (%s)", user.DisplayName) } fmt.Fprintln(cmd.OutOrStdout()) - if len(me.Perimeters) > 0 { + if len(perimeters) > 0 { fmt.Fprintln(cmd.OutOrStdout(), "\nPerimeters:") - for _, p := range me.Perimeters { + for _, p := range perimeters { fmt.Fprintf(cmd.OutOrStdout(), " %s (%s)\n", p.Name, p.Role) } } diff --git a/cli/cmd/sync.go b/cli/cmd/sync.go index 13450cb..7aaec47 100644 --- a/cli/cmd/sync.go +++ b/cli/cmd/sync.go @@ -12,6 +12,7 @@ import ( "time" "github.com/cordon-co/cordon-cli/cli/internal/api" + "github.com/cordon-co/cordon-cli/cli/internal/apicontract" "github.com/cordon-co/cordon-cli/cli/internal/flags" "github.com/cordon-co/cordon-cli/cli/internal/policysync" "github.com/cordon-co/cordon-cli/cli/internal/reporoot" @@ -249,15 +250,8 @@ func syncPolicyPush(policyDB *sql.DB, client *api.Client, perimeterID string) (i return pushed, nil } -type policyPushRequest struct { - Events []store.PolicyEvent `json:"events"` - LastKnownServerSeq int64 `json:"last_known_server_seq"` -} - -type policyPushResponse struct { - Accepted int `json:"accepted"` - ServerSeqAssignments map[string]int64 `json:"server_seq_assignments"` -} +type policyPushRequest = apicontract.PolicyPushRequest +type policyPushResponse = apicontract.PolicyPushResponse func pushEvents(policyDB *sql.DB, client *api.Client, perimeterID string, events []store.PolicyEvent) (int, error) { maxSeq, err := store.MaxServerSeq(policyDB) @@ -266,9 +260,20 @@ func pushEvents(policyDB *sql.DB, client *api.Client, perimeterID string, events } var resp policyPushResponse + wireEvents := make([]apicontract.PolicyEvent, 0, len(events)) + for _, e := range events { + wireEvents = append(wireEvents, apicontract.PolicyEvent{ + EventId: e.EventID, + EventType: e.EventType, + Payload: e.Payload, + Actor: e.Actor, + Timestamp: e.Timestamp, + ServerSeq: e.ServerSeq, + }) + } _, err = client.PostJSON( fmt.Sprintf("/api/v1/perimeters/%s/policy/events", perimeterID), - policyPushRequest{Events: events, LastKnownServerSeq: maxSeq}, + policyPushRequest{Events: wireEvents, LastKnownServerSeq: maxSeq}, &resp, ) if err != nil { @@ -291,7 +296,7 @@ func pushEvents(policyDB *sql.DB, client *api.Client, perimeterID string, events } _, err = client.PostJSON( fmt.Sprintf("/api/v1/perimeters/%s/policy/events", perimeterID), - policyPushRequest{Events: newUnpushed, LastKnownServerSeq: newMaxSeq}, + policyPushRequest{Events: wireEventsFromStore(newUnpushed), LastKnownServerSeq: newMaxSeq}, &resp, ) if err != nil { @@ -309,105 +314,8 @@ func pushEvents(policyDB *sql.DB, client *api.Client, perimeterID string, events } // --- Data Ingest --- - -// ingestHookLogEntry matches the spec §4.1 hook_log item shape (includes id). -type ingestHookLogEntry struct { - ID int64 `json:"id"` - Ts int64 `json:"ts"` - ToolName string `json:"tool_name"` - FilePath string `json:"file_path"` - ToolInput string `json:"tool_input"` - CommandRaw string `json:"command_raw"` - CommandParsed bool `json:"command_parsed_ok"` - CommandParseError string `json:"command_parse_error"` - CommandParser string `json:"command_parser"` - CommandParserVersion string `json:"command_parser_version"` - CommandOpsJSON string `json:"command_ops_json"` - DeniedOpIndex int `json:"denied_op_index"` - DeniedOpReason string `json:"denied_op_reason"` - MatchedRulePattern string `json:"matched_rule_pattern"` - MatchedRuleType string `json:"matched_rule_type"` - Ambiguity string `json:"ambiguity"` - Decision string `json:"decision"` - OSUser string `json:"os_user"` - Agent string `json:"agent"` - PassID string `json:"pass_id"` - Notify bool `json:"notify"` - SessionID string `json:"session_id"` - TranscriptPath string `json:"transcript_path"` - SecretsDetected int `json:"secrets_detected"` - SecretRuleIDs string `json:"secret_rule_ids"` - ParentHash string `json:"parent_hash"` - Hash string `json:"hash"` -} - -// ingestAuditEntry matches the spec §4.1 audit_log item shape (includes id). -type ingestAuditEntry struct { - ID int64 `json:"id"` - EventType string `json:"event_type"` - FilePath string `json:"file_path"` - User string `json:"user"` - Detail string `json:"detail"` - Timestamp string `json:"timestamp"` - ParentHash string `json:"parent_hash"` - Hash string `json:"hash"` -} - -// ingestPass matches the spec §4.1 passes item shape. -type ingestPass struct { - ID string `json:"id"` - FileRuleID string `json:"file_rule_id"` - Pattern string `json:"pattern"` - Status string `json:"status"` - IssuedTo string `json:"issued_to"` - IssuedBy string `json:"issued_by"` - IssuedAt string `json:"issued_at"` - ExpiresAt string `json:"expires_at"` -} - -// ingestSession matches the spec §4.1 sessions item shape. -type ingestSession struct { - SessionID string `json:"session_id"` - Agent string `json:"agent"` - Description string `json:"description"` - TranscriptPath string `json:"transcript_path"` - InputTokens int64 `json:"input_tokens"` - OutputTokens int64 `json:"output_tokens"` - CacheReadTokens int64 `json:"cache_read_tokens"` - FirstSeenAt int64 `json:"first_seen_at"` - LastSeenAt int64 `json:"last_seen_at"` - UpdatedAt int64 `json:"updated_at"` -} - -type ingestWatermarks struct { - HookLog int64 `json:"hook_log"` - AuditLog int64 `json:"audit_log"` - PassesLastSyncedAt string `json:"passes_last_synced_at"` - Sessions int64 `json:"sessions"` -} - -type ingestRequest struct { - ClientID string `json:"client_id"` - HookLog []ingestHookLogEntry `json:"hook_log"` - AuditLog []ingestAuditEntry `json:"audit_log"` - Passes []ingestPass `json:"passes"` - Sessions []ingestSession `json:"sessions"` - Watermarks ingestWatermarks `json:"watermarks"` -} - -type ingestResponse struct { - Accepted struct { - HookLog int `json:"hook_log"` - AuditLog int `json:"audit_log"` - Passes int `json:"passes"` - Sessions int `json:"sessions"` - } `json:"accepted"` - ChainStatus struct { - HookLog string `json:"hook_log"` - AuditLog string `json:"audit_log"` - } `json:"chain_status"` - NotificationsTriggered int `json:"notifications_triggered"` -} +type ingestRequest = apicontract.DataIngestRequest +type ingestResponse = apicontract.DataIngestResponse // ingestBatchSize is the maximum number of entries per table per ingest POST. // If any table has more entries than this, multiple POSTs are made with @@ -461,75 +369,75 @@ func syncDataPush(dataDB *sql.DB, client *api.Client, perimeterID, clientID stri } // Convert to spec-shaped structs. - hookItems := make([]ingestHookLogEntry, len(hookEntries)) + hookItems := make([]apicontract.HookLogEntry, len(hookEntries)) for i, e := range hookEntries { secretsDetected := 0 if e.SecretsDetected { secretsDetected = 1 } - hookItems[i] = ingestHookLogEntry{ - ID: e.ID, + hookItems[i] = apicontract.HookLogEntry{ + Id: e.ID, Ts: e.Ts, ToolName: e.ToolName, FilePath: e.FilePath, - ToolInput: e.ToolInput, - CommandRaw: e.CommandRaw, - CommandParsed: e.CommandParsed, - CommandParseError: e.CommandParseError, - CommandParser: e.CommandParser, - CommandParserVersion: e.CommandParserVersion, - CommandOpsJSON: e.CommandOpsJSON, - DeniedOpIndex: e.DeniedOpIndex, - DeniedOpReason: e.DeniedOpReason, - MatchedRulePattern: e.MatchedRulePattern, - MatchedRuleType: e.MatchedRuleType, - Ambiguity: e.Ambiguity, + ToolInput: ptr(e.ToolInput), + CommandRaw: ptr(e.CommandRaw), + CommandParsedOk: ptr(e.CommandParsed), + CommandParseError: ptr(e.CommandParseError), + CommandParser: ptr(e.CommandParser), + CommandParserVersion: ptr(e.CommandParserVersion), + CommandOpsJson: ptr(e.CommandOpsJSON), + DeniedOpIndex: ptr(e.DeniedOpIndex), + DeniedOpReason: ptr(e.DeniedOpReason), + MatchedRulePattern: ptr(e.MatchedRulePattern), + MatchedRuleType: ptr(e.MatchedRuleType), + Ambiguity: ptr(e.Ambiguity), Decision: e.Decision, - OSUser: e.OSUser, - Agent: e.Agent, - PassID: e.PassID, - Notify: e.Notify, - SessionID: e.SessionID, - TranscriptPath: e.TranscriptPath, - SecretsDetected: secretsDetected, - SecretRuleIDs: e.SecretRuleIDs, - ParentHash: e.ParentHash, - Hash: e.Hash, + OsUser: nilIfEmpty(e.OSUser), + Agent: nilIfEmpty(e.Agent), + PassId: nilIfEmpty(e.PassID), + Notify: ptr(e.Notify), + SessionId: nilIfEmpty(e.SessionID), + TranscriptPath: nilIfEmpty(e.TranscriptPath), + SecretsDetected: ptr(secretsDetected), + SecretRuleIds: nilIfEmpty(e.SecretRuleIDs), + ParentHash: nilIfEmpty(e.ParentHash), + Hash: nilIfEmpty(e.Hash), } } - auditItems := make([]ingestAuditEntry, len(auditEntries)) + auditItems := make([]apicontract.AuditLogEntry, len(auditEntries)) for i, e := range auditEntries { - auditItems[i] = ingestAuditEntry{ - ID: e.ID, + auditItems[i] = apicontract.AuditLogEntry{ + Id: e.ID, EventType: e.EventType, - FilePath: e.FilePath, - User: e.User, - Detail: e.Detail, - Timestamp: e.Timestamp, - ParentHash: e.ParentHash, - Hash: e.Hash, + FilePath: nilIfEmpty(e.FilePath), + User: nilIfEmpty(e.User), + Detail: nilIfEmpty(e.Detail), + Timestamp: mustParseRFC3339(e.Timestamp), + ParentHash: nilIfEmpty(e.ParentHash), + Hash: nilIfEmpty(e.Hash), } } - passItems := make([]ingestPass, len(passes)) + passItems := make([]apicontract.Pass, len(passes)) for i, p := range passes { - passItems[i] = ingestPass{ - ID: p.ID, - FileRuleID: p.FileRuleID, + passItems[i] = apicontract.Pass{ + Id: p.ID, + FileRuleId: nilIfEmpty(p.FileRuleID), Pattern: p.Pattern, Status: p.Status, IssuedTo: p.IssuedTo, IssuedBy: p.IssuedBy, - IssuedAt: p.IssuedAt, - ExpiresAt: p.ExpiresAt, + IssuedAt: mustParseRFC3339(p.IssuedAt), + ExpiresAt: parseOptionalRFC3339(p.ExpiresAt), } } - sessionItems := make([]ingestSession, len(sessions)) + sessionItems := make([]apicontract.Session, len(sessions)) for i, s := range sessions { - sessionItems[i] = ingestSession{ - SessionID: s.SessionID, + sessionItems[i] = apicontract.Session{ + SessionId: s.SessionID, Agent: s.Agent, Description: s.Description, TranscriptPath: s.TranscriptPath, @@ -560,16 +468,16 @@ func syncDataPush(dataDB *sql.DB, client *api.Client, perimeterID, clientID stri _, err = client.PostJSON( fmt.Sprintf("/api/v1/perimeters/%s/data/ingest", perimeterID), ingestRequest{ - ClientID: clientID, - HookLog: hookItems, - AuditLog: auditItems, - Passes: passItems, - Sessions: sessionItems, - Watermarks: ingestWatermarks{ - HookLog: newHookWM, - AuditLog: newAuditWM, - PassesLastSyncedAt: time.Now().UTC().Format(time.RFC3339), - Sessions: newSessionsWM, + ClientId: ptr(clientID), + HookLog: &hookItems, + AuditLog: &auditItems, + Passes: &passItems, + Sessions: &sessionItems, + Watermarks: &apicontract.IngestWatermarks{ + HookLog: ptr(newHookWM), + AuditLog: ptr(newAuditWM), + PassesLastSyncedAt: ptr(time.Now().UTC()), + Sessions: ptr(newSessionsWM), }, }, &resp, @@ -611,3 +519,46 @@ func syncDataPush(dataDB *sql.DB, client *api.Client, perimeterID, clientID stri return totalPushed, nil } + +func wireEventsFromStore(events []store.PolicyEvent) []apicontract.PolicyEvent { + out := make([]apicontract.PolicyEvent, 0, len(events)) + for _, e := range events { + out = append(out, apicontract.PolicyEvent{ + EventId: e.EventID, + EventType: e.EventType, + Payload: e.Payload, + Actor: e.Actor, + Timestamp: e.Timestamp, + ServerSeq: e.ServerSeq, + }) + } + return out +} + +func mustParseRFC3339(v string) time.Time { + if t, err := time.Parse(time.RFC3339, v); err == nil { + return t + } + return time.Now().UTC() +} + +func parseOptionalRFC3339(v string) *time.Time { + if v == "" { + return nil + } + if t, err := time.Parse(time.RFC3339, v); err == nil { + return &t + } + return nil +} + +func nilIfEmpty(v string) *string { + if v == "" { + return nil + } + return &v +} + +func ptr[T any](v T) *T { + return &v +} diff --git a/cli/cmd/sync_test.go b/cli/cmd/sync_test.go index 89222a7..c46b81e 100644 --- a/cli/cmd/sync_test.go +++ b/cli/cmd/sync_test.go @@ -58,13 +58,17 @@ func TestSyncDataPush_IncludesSecretFields(t *testing.T) { if pushed != 1 { t.Fatalf("pushed = %d, want 1", pushed) } - if len(got.HookLog) != 1 { - t.Fatalf("hook_log length = %d, want 1", len(got.HookLog)) + if got.HookLog == nil || len(*got.HookLog) != 1 { + l := 0 + if got.HookLog != nil { + l = len(*got.HookLog) + } + t.Fatalf("hook_log length = %d, want 1", l) } - if got.HookLog[0].SecretsDetected != 1 { - t.Fatalf("secrets_detected = %d, want 1", got.HookLog[0].SecretsDetected) + if (*got.HookLog)[0].SecretsDetected == nil || *(*got.HookLog)[0].SecretsDetected != 1 { + t.Fatalf("secrets_detected = %v, want 1", (*got.HookLog)[0].SecretsDetected) } - if got.HookLog[0].SecretRuleIDs != `["github-pat"]` { - t.Fatalf("secret_rule_ids = %q, want [\"github-pat\"]", got.HookLog[0].SecretRuleIDs) + if (*got.HookLog)[0].SecretRuleIds == nil || *(*got.HookLog)[0].SecretRuleIds != `["github-pat"]` { + t.Fatalf("secret_rule_ids = %v, want [\"github-pat\"]", (*got.HookLog)[0].SecretRuleIds) } } diff --git a/cli/internal/apicontract/gen_types.go b/cli/internal/apicontract/gen_types.go new file mode 100644 index 0000000..07fbcfd --- /dev/null +++ b/cli/internal/apicontract/gen_types.go @@ -0,0 +1,357 @@ +// Package apicontract provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT. +package apicontract + +import ( + "time" +) + +const ( + BearerAuthScopes = "bearerAuth.Scopes" +) + +// Defines values for AuthorizationPendingResponseError. +const ( + AuthorizationPending AuthorizationPendingResponseError = "authorization_pending" +) + +// Defines values for BillingRequiredResponseErrorCode. +const ( + BillingRequired BillingRequiredResponseErrorCode = "billing_required" +) + +// Defines values for BillingRequiredResponseErrorState. +const ( + ActivePaid BillingRequiredResponseErrorState = "active_paid" + GrantActive BillingRequiredResponseErrorState = "grant_active" + Inactive BillingRequiredResponseErrorState = "inactive" + ManualOverride BillingRequiredResponseErrorState = "manual_override" + NoSubscription BillingRequiredResponseErrorState = "no_subscription" + PastDue BillingRequiredResponseErrorState = "past_due" + Trial BillingRequiredResponseErrorState = "trial" +) + +// Defines values for ChainStatus. +const ( + Broken ChainStatus = "broken" + Unverified ChainStatus = "unverified" + Valid ChainStatus = "valid" +) + +// Defines values for DeviceCodeRequestClientId. +const ( + CordonCli DeviceCodeRequestClientId = "cordon-cli" +) + +// Defines values for EventsBehindResponseError. +const ( + EventsBehind EventsBehindResponseError = "events_behind" +) + +// Defines values for Role. +const ( + Admin Role = "admin" + Member Role = "member" +) + +// Defines values for TokenRequestGrantType. +const ( + UrnIetfParamsOauthGrantTypeDeviceCode TokenRequestGrantType = "urn:ietf:params:oauth:grant-type:device_code" +) + +// Defines values for TokenResponseTokenType. +const ( + Bearer TokenResponseTokenType = "bearer" +) + +// AuditLogEntry defines model for AuditLogEntry. +type AuditLogEntry struct { + Agent *string `json:"agent,omitempty"` + Detail *string `json:"detail,omitempty"` + EventType string `json:"event_type"` + FilePath *string `json:"file_path,omitempty"` + FileRuleId *string `json:"file_rule_id,omitempty"` + Hash *string `json:"hash,omitempty"` + Id int64 `json:"id"` + ParentHash *string `json:"parent_hash,omitempty"` + PassId *string `json:"pass_id,omitempty"` + Timestamp time.Time `json:"timestamp"` + ToolName *string `json:"tool_name,omitempty"` + User *string `json:"user,omitempty"` +} + +// AuthorizationPendingResponse defines model for AuthorizationPendingResponse. +type AuthorizationPendingResponse struct { + Error AuthorizationPendingResponseError `json:"error"` +} + +// AuthorizationPendingResponseError defines model for AuthorizationPendingResponse.Error. +type AuthorizationPendingResponseError string + +// BillingRequiredResponse defines model for BillingRequiredResponse. +type BillingRequiredResponse struct { + Error struct { + Code BillingRequiredResponseErrorCode `json:"code"` + Message string `json:"message"` + State BillingRequiredResponseErrorState `json:"state"` + } `json:"error"` +} + +// BillingRequiredResponseErrorCode defines model for BillingRequiredResponse.Error.Code. +type BillingRequiredResponseErrorCode string + +// BillingRequiredResponseErrorState defines model for BillingRequiredResponse.Error.State. +type BillingRequiredResponseErrorState string + +// ChainStatus defines model for ChainStatus. +type ChainStatus string + +// DataIngestAccepted defines model for DataIngestAccepted. +type DataIngestAccepted struct { + AuditLog int `json:"audit_log"` + HookLog int `json:"hook_log"` + Passes int `json:"passes"` + Sessions int `json:"sessions"` +} + +// DataIngestChainStatus defines model for DataIngestChainStatus. +type DataIngestChainStatus struct { + AuditLog *ChainStatus `json:"audit_log,omitempty"` + HookLog *ChainStatus `json:"hook_log,omitempty"` +} + +// DataIngestRequest Current server behavior accepts sparse payloads; all top-level properties are optional. +// Older clients may omit client_id. +type DataIngestRequest struct { + AuditLog *[]AuditLogEntry `json:"audit_log,omitempty"` + + // ClientId UUID preferred. Non-UUID values are accepted. + ClientId *string `json:"client_id,omitempty"` + HookLog *[]HookLogEntry `json:"hook_log,omitempty"` + Passes *[]Pass `json:"passes,omitempty"` + Sessions *[]Session `json:"sessions,omitempty"` + Watermarks *IngestWatermarks `json:"watermarks,omitempty"` +} + +// DataIngestResponse defines model for DataIngestResponse. +type DataIngestResponse struct { + Accepted DataIngestAccepted `json:"accepted"` + ChainStatus DataIngestChainStatus `json:"chain_status"` + ClientId string `json:"client_id"` + NotificationsTriggered int `json:"notifications_triggered"` +} + +// DeviceCodeRequest defines model for DeviceCodeRequest. +type DeviceCodeRequest struct { + ClientId DeviceCodeRequestClientId `json:"client_id"` +} + +// DeviceCodeRequestClientId defines model for DeviceCodeRequest.ClientId. +type DeviceCodeRequestClientId string + +// DeviceCodeResponse defines model for DeviceCodeResponse. +type DeviceCodeResponse struct { + DeviceCode string `json:"device_code"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` + UserCode string `json:"user_code"` + VerificationUri string `json:"verification_uri"` +} + +// ErrorResponse defines model for ErrorResponse. +type ErrorResponse struct { + Error string `json:"error"` + Message *string `json:"message,omitempty"` +} + +// EventsBehindResponse defines model for EventsBehindResponse. +type EventsBehindResponse struct { + Error EventsBehindResponseError `json:"error"` + Message *string `json:"message,omitempty"` +} + +// EventsBehindResponseError defines model for EventsBehindResponse.Error. +type EventsBehindResponseError string + +// HookLogEntry defines model for HookLogEntry. +type HookLogEntry struct { + Agent *string `json:"agent,omitempty"` + Ambiguity *string `json:"ambiguity,omitempty"` + CommandOpsJson *string `json:"command_ops_json,omitempty"` + CommandParseError *string `json:"command_parse_error,omitempty"` + CommandParsedOk *bool `json:"command_parsed_ok,omitempty"` + CommandParser *string `json:"command_parser,omitempty"` + CommandParserVersion *string `json:"command_parser_version,omitempty"` + CommandRaw *string `json:"command_raw,omitempty"` + Decision string `json:"decision"` + DeniedOpIndex *int `json:"denied_op_index,omitempty"` + DeniedOpReason *string `json:"denied_op_reason,omitempty"` + FilePath string `json:"file_path"` + Hash *string `json:"hash,omitempty"` + Id int64 `json:"id"` + MatchedRulePattern *string `json:"matched_rule_pattern,omitempty"` + MatchedRuleType *string `json:"matched_rule_type,omitempty"` + Notify *bool `json:"notify,omitempty"` + OsUser *string `json:"os_user,omitempty"` + ParentHash *string `json:"parent_hash,omitempty"` + PassId *string `json:"pass_id,omitempty"` + SecretRuleIds *string `json:"secret_rule_ids,omitempty"` + SecretsDetected *int `json:"secrets_detected,omitempty"` + SessionId *string `json:"session_id,omitempty"` + ToolInput *string `json:"tool_input,omitempty"` + ToolName string `json:"tool_name"` + TranscriptPath *string `json:"transcript_path,omitempty"` + + // Ts Unix timestamp in microseconds. + Ts int64 `json:"ts"` +} + +// IngestWatermarks defines model for IngestWatermarks. +type IngestWatermarks struct { + AuditLog *int64 `json:"audit_log,omitempty"` + HookLog *int64 `json:"hook_log,omitempty"` + PassesLastSyncedAt *time.Time `json:"passes_last_synced_at,omitempty"` + Sessions *int64 `json:"sessions,omitempty"` +} + +// MeResponse defines model for MeResponse. +type MeResponse struct { + Perimeters []PerimeterSummary `json:"perimeters"` + User UserProfile `json:"user"` +} + +// Pass defines model for Pass. +type Pass struct { + ExpiresAt *time.Time `json:"expires_at,omitempty"` + FileRuleId *string `json:"file_rule_id,omitempty"` + Id string `json:"id"` + IssuedAt time.Time `json:"issued_at"` + IssuedBy string `json:"issued_by"` + IssuedTo string `json:"issued_to"` + Pattern string `json:"pattern"` + RevokedAt *time.Time `json:"revoked_at,omitempty"` + RevokedBy *string `json:"revoked_by,omitempty"` + Status string `json:"status"` +} + +// PerimeterLookupResponse defines model for PerimeterLookupResponse. +type PerimeterLookupResponse struct { + Name string `json:"name"` + PerimeterId string `json:"perimeter_id"` + Role Role `json:"role"` +} + +// PerimeterSummary defines model for PerimeterSummary. +type PerimeterSummary struct { + Id string `json:"id"` + Name string `json:"name"` + Role Role `json:"role"` +} + +// PolicyEvent defines model for PolicyEvent. +type PolicyEvent struct { + Actor string `json:"actor"` + EventId string `json:"event_id"` + EventType string `json:"event_type"` + Hash *string `json:"hash,omitempty"` + ParentHash *string `json:"parent_hash,omitempty"` + + // Payload JSON string payload. + Payload string `json:"payload"` + ServerSeq *int64 `json:"server_seq,omitempty"` + + // Timestamp ISO 8601 string in current implementations. + Timestamp string `json:"timestamp"` +} + +// PolicyPullResponse defines model for PolicyPullResponse. +type PolicyPullResponse struct { + Events []PolicyEvent `json:"events"` + HasMore bool `json:"has_more"` +} + +// PolicyPushRequest defines model for PolicyPushRequest. +type PolicyPushRequest struct { + Events []PolicyEvent `json:"events"` + LastKnownServerSeq int64 `json:"last_known_server_seq"` +} + +// PolicyPushResponse defines model for PolicyPushResponse. +type PolicyPushResponse struct { + Accepted int `json:"accepted"` + ServerSeqAssignments map[string]int64 `json:"server_seq_assignments"` +} + +// Role defines model for Role. +type Role string + +// Session defines model for Session. +type Session struct { + Agent string `json:"agent"` + CacheReadTokens int64 `json:"cache_read_tokens"` + Description string `json:"description"` + FirstSeenAt int64 `json:"first_seen_at"` + InputTokens int64 `json:"input_tokens"` + LastSeenAt int64 `json:"last_seen_at"` + OutputTokens int64 `json:"output_tokens"` + SessionId string `json:"session_id"` + TranscriptPath string `json:"transcript_path"` + UpdatedAt int64 `json:"updated_at"` +} + +// TokenRequest defines model for TokenRequest. +type TokenRequest struct { + DeviceCode string `json:"device_code"` + GrantType TokenRequestGrantType `json:"grant_type"` +} + +// TokenRequestGrantType defines model for TokenRequest.GrantType. +type TokenRequestGrantType string + +// TokenResponse defines model for TokenResponse. +type TokenResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + TokenType TokenResponseTokenType `json:"token_type"` + User UserProfile `json:"user"` +} + +// TokenResponseTokenType defines model for TokenResponse.TokenType. +type TokenResponseTokenType string + +// UserProfile defines model for UserProfile. +type UserProfile struct { + AvatarUrl string `json:"avatar_url"` + DisplayName string `json:"display_name"` + Id string `json:"id"` + Username string `json:"username"` +} + +// PerimeterId defines model for PerimeterId. +type PerimeterId = string + +// PerimetersLookupParams defines parameters for PerimetersLookup. +type PerimetersLookupParams struct { + PerimeterId string `form:"perimeter_id" json:"perimeter_id"` +} + +// PolicyEventsPullParams defines parameters for PolicyEventsPull. +type PolicyEventsPullParams struct { + AfterServerSeq int64 `form:"after_server_seq" json:"after_server_seq"` + + // Limit Clamped server-side to 1..1000 + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` +} + +// AuthDeviceJSONRequestBody defines body for AuthDevice for application/json ContentType. +type AuthDeviceJSONRequestBody = DeviceCodeRequest + +// AuthTokenJSONRequestBody defines body for AuthToken for application/json ContentType. +type AuthTokenJSONRequestBody = TokenRequest + +// DataIngestJSONRequestBody defines body for DataIngest for application/json ContentType. +type DataIngestJSONRequestBody = DataIngestRequest + +// PolicyEventsPushJSONRequestBody defines body for PolicyEventsPush for application/json ContentType. +type PolicyEventsPushJSONRequestBody = PolicyPushRequest diff --git a/cli/internal/apicontract/oapi-codegen.yaml b/cli/internal/apicontract/oapi-codegen.yaml new file mode 100644 index 0000000..3ac9ed5 --- /dev/null +++ b/cli/internal/apicontract/oapi-codegen.yaml @@ -0,0 +1,6 @@ +package: apicontract +output: cli/internal/apicontract/gen_types.go +generate: + models: true +output-options: + skip-prune: true diff --git a/cli/internal/policysync/policysync.go b/cli/internal/policysync/policysync.go index ef43e55..ec9c9c9 100644 --- a/cli/internal/policysync/policysync.go +++ b/cli/internal/policysync/policysync.go @@ -7,19 +7,14 @@ import ( "net/url" "github.com/cordon-co/cordon-cli/cli/internal/api" + "github.com/cordon-co/cordon-cli/cli/internal/apicontract" "github.com/cordon-co/cordon-cli/cli/internal/store" ) -type lookupResponse struct { - PerimeterID string `json:"perimeter_id"` - Name string `json:"name"` - Role string `json:"role"` -} - // LookupPerimeter checks whether the given perimeter is registered remotely. // Returns (remotePerimeterID, true, nil) when registered, ("", false, nil) when not found. func LookupPerimeter(client *api.Client, perimeterID string) (string, bool, error) { - var resp lookupResponse + var resp apicontract.PerimeterLookupResponse _, err := client.GetJSON( fmt.Sprintf("/api/v1/perimeters/lookup?perimeter_id=%s", url.QueryEscape(perimeterID)), &resp, @@ -30,7 +25,7 @@ func LookupPerimeter(client *api.Client, perimeterID string) (string, bool, erro } return "", false, err } - return resp.PerimeterID, true, nil + return resp.PerimeterId, true, nil } // PullEvents pulls policy events after local max(server_seq) and appends them @@ -43,10 +38,7 @@ func PullEvents(policyDB *sql.DB, client *api.Client, perimeterID string) (int, } for { - var pullResp struct { - Events []store.PolicyEvent `json:"events"` - HasMore bool `json:"has_more"` - } + var pullResp apicontract.PolicyPullResponse _, err = client.GetJSON( fmt.Sprintf("/api/v1/perimeters/%s/policy/events?after_server_seq=%d&limit=1000", perimeterID, afterSeq), &pullResp, @@ -55,18 +47,30 @@ func PullEvents(policyDB *sql.DB, client *api.Client, perimeterID string) (int, return totalPulled, err } - if len(pullResp.Events) == 0 { + events := make([]store.PolicyEvent, 0, len(pullResp.Events)) + for _, e := range pullResp.Events { + events = append(events, store.PolicyEvent{ + EventID: e.EventId, + EventType: e.EventType, + Payload: e.Payload, + Actor: e.Actor, + Timestamp: e.Timestamp, + ServerSeq: e.ServerSeq, + }) + } + + if len(events) == 0 { break } - if err := store.AppendRemoteEvents(policyDB, pullResp.Events); err != nil { + if err := store.AppendRemoteEvents(policyDB, events); err != nil { return totalPulled, err } - totalPulled += len(pullResp.Events) + totalPulled += len(events) if !pullResp.HasMore { break } - lastEvent := pullResp.Events[len(pullResp.Events)-1] + lastEvent := events[len(events)-1] if lastEvent.ServerSeq == nil { break }