diff --git a/go.mod b/go.mod index c516897..fa13dce 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module github.com/InstaNode-dev/sdk-go go 1.25 -toolchain go1.25.11 +toolchain go1.25.12 diff --git a/instant/audit.go b/instant/audit.go new file mode 100644 index 0000000..84c0fdb --- /dev/null +++ b/instant/audit.go @@ -0,0 +1,180 @@ +package instant + +// audit.go — customer-facing audit log export (GET /api/v1/audit). +// +// Every team action — resource provisioned, deployment started, vault key +// written, billing event received — is recorded as an immutable audit row. +// This client method makes that log queryable from Go: stream it into a SIEM, +// build a compliance report, or inspect recent activity in CI. +// +// Tier gate (enforced server-side; client surfaces the *APIError): +// +// anonymous / free → 402 upgrade_required +// hobby → 30-day lookback +// hobby_plus → 60-day lookback +// pro → 90-day lookback +// growth / team → unlimited history +// +// Pagination: the response includes a NextCursor timestamp. Pass it as +// Before on the next call to page backward in time. An empty NextCursor +// means no more rows match the current filters. + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "strconv" + "time" +) + +// AuditEvent is one row from the team's audit log. +type AuditEvent struct { + // ID is the unique UUID for this audit event. + ID string `json:"id"` + + // Kind is the event category, e.g. "resource.created", "deploy.healthy", + // "billing.upgraded". See the OpenAPI spec for the full kind catalogue. + Kind string `json:"kind"` + + // CreatedAt is when the event was recorded (UTC). + CreatedAt time.Time `json:"created_at"` + + // Actor is the high-level action source: "user", "system", or "agent". + Actor string `json:"actor"` + + // ActorUserID is the UUID of the user who triggered the event. + // Nil when the event was emitted by the system or an API key with + // no associated user session. + ActorUserID *string `json:"actor_user_id"` + + // ActorEmailMasked is the actor's email address with the local-part + // partially redacted (e.g. "a***@example.com"). Nil when ActorUserID is nil. + ActorEmailMasked *string `json:"actor_email_masked"` + + // ResourceID is the UUID of the resource this event relates to. + // Nil for team-scoped events (billing, settings) that have no single resource. + ResourceID *string `json:"resource_id"` + + // ResourceType is the kind of resource ("postgres", "redis", "deployment", …). + // Empty when ResourceID is nil. + ResourceType string `json:"resource_type"` + + // Summary is a human-readable one-line description of what happened, + // safe to display in a dashboard or log. + Summary string `json:"summary"` + + // Metadata holds event-specific structured data. The shape varies by Kind. + // Use RawMetadata to access the raw JSON if the typed fields are insufficient. + Metadata json.RawMessage `json:"metadata"` +} + +// AuditEventList is the paginated response from [Client.ListAuditEvents] and +// [Client.ListAuditEventsPage]. +type AuditEventList struct { + // OK is always true on success. + OK bool `json:"ok"` + + // Items is the page of audit events, newest first. + Items []AuditEvent `json:"items"` + + // TotalReturned is the number of items in this response. + TotalReturned int `json:"total_returned"` + + // NextCursor is the opaque cursor for the next page. Pass it as + // [ListAuditEventsOpts.Before] to retrieve older events. Empty when + // there are no more pages. + NextCursor string `json:"next_cursor,omitempty"` + + // LookbackDays is the maximum age of events the team's plan permits. + // -1 means unlimited (growth / team plans). 0 means the tier cannot + // access audit logs (this value only appears in successful responses, + // so 0 should not occur in practice). + LookbackDays int `json:"lookback_days"` + + // Tier is the team's plan tier that determined the lookback window. + Tier string `json:"tier"` +} + +// ListAuditEventsOpts controls filtering and pagination for audit log queries. +type ListAuditEventsOpts struct { + // Limit is the maximum number of events to return (1–200). Default 50. + Limit int + + // Kind filters results to a single event kind, e.g. "resource.created". + // Empty string returns all kinds. + Kind string + + // Before returns only events older than this timestamp (exclusive). + // Use the NextCursor from a previous response for keyset pagination. + Before time.Time + + // Since returns only events at or after this timestamp. + Since time.Time + + // Until returns only events at or before this timestamp. + Until time.Time +} + +const auditPath = "/api/v1/audit" + +// ListAuditEvents returns the most recent page of audit events for the +// authenticated team. Requires a valid API key (Bearer token). +// +// Equivalent to calling [Client.ListAuditEventsPage] with zero-value opts. +// +// Example: +// +// list, err := client.ListAuditEvents(ctx) +// if err != nil { log.Fatal(err) } +// for _, ev := range list.Items { +// fmt.Printf("%s %-32s %s\n", ev.CreatedAt.Format(time.RFC3339), ev.Kind, ev.Summary) +// } +func (c *Client) ListAuditEvents(ctx context.Context) (*AuditEventList, error) { + return c.ListAuditEventsPage(ctx, ListAuditEventsOpts{}) +} + +// ListAuditEventsPage returns one page of audit events honouring the supplied +// filter options. Requires a valid API key (Bearer token). +// +// Results are ordered newest-first. Use NextCursor as opts.Before to paginate: +// +// opts := instant.ListAuditEventsOpts{Limit: 100} +// for { +// page, err := client.ListAuditEventsPage(ctx, opts) +// if err != nil { return err } +// for _, ev := range page.Items { process(ev) } +// if page.NextCursor == "" { break } +// // NextCursor is an RFC3339 timestamp — assign directly to Before. +// t, _ := time.Parse(time.RFC3339Nano, page.NextCursor) +// opts.Before = t +// } +func (c *Client) ListAuditEventsPage(ctx context.Context, opts ListAuditEventsOpts) (*AuditEventList, error) { + q := url.Values{} + if opts.Limit > 0 { + q.Set("limit", strconv.Itoa(opts.Limit)) + } + if opts.Kind != "" { + q.Set("kind", opts.Kind) + } + if !opts.Before.IsZero() { + q.Set("before", opts.Before.UTC().Format(time.RFC3339)) + } + if !opts.Since.IsZero() { + q.Set("since", opts.Since.UTC().Format(time.RFC3339)) + } + if !opts.Until.IsZero() { + q.Set("until", opts.Until.UTC().Format(time.RFC3339)) + } + + path := auditPath + if enc := q.Encode(); enc != "" { + path += "?" + enc + } + + var raw AuditEventList + if err := c.get(ctx, path, &raw); err != nil { + return nil, fmt.Errorf("ListAuditEvents: %w", err) + } + return &raw, nil +} diff --git a/instant/audit_test.go b/instant/audit_test.go new file mode 100644 index 0000000..62ce84b --- /dev/null +++ b/instant/audit_test.go @@ -0,0 +1,242 @@ +package instant + +// audit_test.go — exercises ListAuditEvents / ListAuditEventsPage against +// httptest servers mirroring the api audit handler envelope +// (api/internal/handlers/audit.go: {ok, items, total_returned, next_cursor, +// lookback_days, tier}). + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// auditServer builds an httptest server that records the incoming request +// and responds with the given status + body. Uses the operateCall recorder +// defined in vault_test.go (same package, same test binary). +func auditServer(t *testing.T, status int, body string) (*httptest.Server, *operateCall) { + t.Helper() + return newOperateServer(t, status, body) +} + +const auditListJSON = `{ + "ok": true, + "items": [ + { + "id": "aabbccdd-0000-0000-0000-000000000001", + "kind": "resource.created", + "created_at": "2026-07-07T10:00:00Z", + "actor": "user", + "actor_user_id": "usr-0001", + "actor_email_masked": "m***@example.com", + "resource_id": "res-0001", + "resource_type": "postgres", + "summary": "Provisioned postgres", + "metadata": {"env": "production"} + } + ], + "total_returned": 1, + "next_cursor": null, + "lookback_days": 30, + "tier": "hobby" +}` + +func TestListAuditEvents_HappyPath(t *testing.T) { + srv, call := auditServer(t, http.StatusOK, auditListJSON) + + c := New(WithBaseURL(srv.URL), WithAPIKey("tok")) + list, err := c.ListAuditEvents(context.Background()) + if err != nil { + t.Fatalf("ListAuditEvents: %v", err) + } + + if call.Method != http.MethodGet { + t.Errorf("method = %q, want GET", call.Method) + } + if call.Path != "/api/v1/audit" { + t.Errorf("path = %q, want /api/v1/audit", call.Path) + } + if call.Query != "" { + t.Errorf("query = %q, want empty (no-opts call)", call.Query) + } + + if !list.OK { + t.Error("ok = false, want true") + } + if len(list.Items) != 1 { + t.Fatalf("items len = %d, want 1", len(list.Items)) + } + ev := list.Items[0] + if ev.Kind != "resource.created" { + t.Errorf("kind = %q", ev.Kind) + } + if ev.Actor != "user" { + t.Errorf("actor = %q", ev.Actor) + } + if ev.ActorUserID == nil || *ev.ActorUserID != "usr-0001" { + t.Errorf("actor_user_id = %v", ev.ActorUserID) + } + if ev.ActorEmailMasked == nil || *ev.ActorEmailMasked != "m***@example.com" { + t.Errorf("actor_email_masked = %v", ev.ActorEmailMasked) + } + if ev.ResourceType != "postgres" { + t.Errorf("resource_type = %q", ev.ResourceType) + } + if list.LookbackDays != 30 { + t.Errorf("lookback_days = %d, want 30", list.LookbackDays) + } + if list.Tier != "hobby" { + t.Errorf("tier = %q, want hobby", list.Tier) + } + + // Metadata should be preserved as raw JSON. + var meta map[string]string + if err := json.Unmarshal(ev.Metadata, &meta); err != nil { + t.Fatalf("metadata decode: %v", err) + } + if meta["env"] != "production" { + t.Errorf("metadata env = %q", meta["env"]) + } +} + +func TestListAuditEvents_SystemActorNilFields(t *testing.T) { + body := `{"ok":true,"items":[{"id":"x","kind":"billing.upgraded","created_at":"2026-07-07T09:00:00Z","actor":"system","actor_user_id":null,"actor_email_masked":null,"resource_id":null,"resource_type":"","summary":"Upgraded to Pro","metadata":null}],"total_returned":1,"lookback_days":-1,"tier":"team"}` + srv, _ := auditServer(t, http.StatusOK, body) + + c := New(WithBaseURL(srv.URL), WithAPIKey("tok")) + list, err := c.ListAuditEvents(context.Background()) + if err != nil { + t.Fatalf("ListAuditEvents: %v", err) + } + ev := list.Items[0] + if ev.ActorUserID != nil { + t.Errorf("actor_user_id should be nil for system actor, got %v", *ev.ActorUserID) + } + if ev.ActorEmailMasked != nil { + t.Errorf("actor_email_masked should be nil, got %v", *ev.ActorEmailMasked) + } + if ev.ResourceID != nil { + t.Errorf("resource_id should be nil, got %v", *ev.ResourceID) + } + if list.LookbackDays != -1 { + t.Errorf("lookback_days = %d, want -1 (unlimited)", list.LookbackDays) + } +} + +func TestListAuditEventsPage_QueryParams(t *testing.T) { + srv, call := auditServer(t, http.StatusOK, `{"ok":true,"items":[],"total_returned":0,"lookback_days":90,"tier":"pro"}`) + + before := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) + since := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + + c := New(WithBaseURL(srv.URL), WithAPIKey("tok")) + _, err := c.ListAuditEventsPage(context.Background(), ListAuditEventsOpts{ + Limit: 100, + Kind: "resource.created", + Before: before, + Since: since, + }) + if err != nil { + t.Fatalf("ListAuditEventsPage: %v", err) + } + + q := call.Query + if !strings.Contains(q, "limit=100") { + t.Errorf("query %q missing limit=100", q) + } + if !strings.Contains(q, "kind=resource.created") { + t.Errorf("query %q missing kind=resource.created", q) + } + if !strings.Contains(q, "before=2026-07-01T12%3A00%3A00Z") && !strings.Contains(q, "before=2026-07-01T12:00:00Z") { + t.Errorf("query %q missing before timestamp", q) + } + if !strings.Contains(q, "since=2026-06-01T00%3A00%3A00Z") && !strings.Contains(q, "since=2026-06-01T00:00:00Z") { + t.Errorf("query %q missing since timestamp", q) + } +} + +func TestListAuditEventsPage_NoQueryParamsWhenZeroOpts(t *testing.T) { + srv, call := auditServer(t, http.StatusOK, `{"ok":true,"items":[],"total_returned":0,"lookback_days":30,"tier":"hobby"}`) + + c := New(WithBaseURL(srv.URL), WithAPIKey("tok")) + _, err := c.ListAuditEventsPage(context.Background(), ListAuditEventsOpts{}) + if err != nil { + t.Fatalf("ListAuditEventsPage: %v", err) + } + if call.Query != "" { + t.Errorf("expected empty query string for zero opts, got %q", call.Query) + } +} + +func TestListAuditEvents_UntilParam(t *testing.T) { + srv, call := auditServer(t, http.StatusOK, `{"ok":true,"items":[],"total_returned":0,"lookback_days":30,"tier":"hobby"}`) + + until := time.Date(2026, 6, 30, 23, 59, 59, 0, time.UTC) + c := New(WithBaseURL(srv.URL), WithAPIKey("tok")) + _, err := c.ListAuditEventsPage(context.Background(), ListAuditEventsOpts{Until: until}) + if err != nil { + t.Fatalf("ListAuditEventsPage: %v", err) + } + if !strings.Contains(call.Query, "until=") { + t.Errorf("query %q missing until param", call.Query) + } +} + +func TestListAuditEvents_UpgradeRequired(t *testing.T) { + body := `{"ok":false,"error":"upgrade_required","message":"Audit log requires Hobby or higher"}` + srv, _ := auditServer(t, http.StatusPaymentRequired, body) + + c := New(WithBaseURL(srv.URL), WithAPIKey("tok")) + _, err := c.ListAuditEvents(context.Background()) + if err == nil { + t.Fatal("expected error for 402, got nil") + } + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("err type = %T, want *APIError", err) + } + if apiErr.StatusCode != http.StatusPaymentRequired { + t.Errorf("status = %d, want 402", apiErr.StatusCode) + } +} + +func TestListAuditEvents_Unauthorized(t *testing.T) { + srv, _ := auditServer(t, http.StatusUnauthorized, + `{"ok":false,"error":"unauthorized","message":"Authentication required"}`) + + c := New(WithBaseURL(srv.URL)) + _, err := c.ListAuditEvents(context.Background()) + if err == nil { + t.Fatal("expected error for 401, got nil") + } + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("err type = %T, want *APIError", err) + } + if apiErr.StatusCode != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", apiErr.StatusCode) + } +} + +func TestListAuditEvents_NextCursor(t *testing.T) { + body := `{"ok":true,"items":[{"id":"x","kind":"deploy.healthy","created_at":"2026-06-30T08:00:00Z","actor":"system","summary":"Deploy healthy"}],"total_returned":1,"next_cursor":"2026-06-30T08:00:00.000000000Z","lookback_days":30,"tier":"hobby"}` + srv, _ := auditServer(t, http.StatusOK, body) + + c := New(WithBaseURL(srv.URL), WithAPIKey("tok")) + list, err := c.ListAuditEvents(context.Background()) + if err != nil { + t.Fatalf("ListAuditEvents: %v", err) + } + if list.NextCursor == "" { + t.Error("NextCursor should be non-empty when a full page is returned") + } + // The cursor is an RFC3339Nano timestamp — verify it parses. + if _, err := time.Parse(time.RFC3339Nano, list.NextCursor); err != nil { + t.Errorf("NextCursor %q is not RFC3339Nano: %v", list.NextCursor, err) + } +}