forked from wrenn/wrenn
Moves 12 packages from internal/ to pkg/ (config, id, validate, events, db, auth, lifecycle, scheduler, channels, audit, service) so they can be imported by the enterprise repo as a Go module dependency. Introduces pkg/cpextension (shared Extension interface + ServerContext) and pkg/cpserver (Run() entrypoint with functional options) so the enterprise main.go can call cpserver.Run(cpserver.WithExtensions(...)) without duplicating the 20-step server bootstrap. Adds db/migrations/embed.go for go:embed access to OSS SQL migrations from the enterprise module. cmd/control-plane/main.go is reduced to a 10-line wrapper around cpserver.Run.
149 lines
4.1 KiB
Go
149 lines
4.1 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
|
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
|
"git.omukk.dev/wrenn/wrenn/pkg/service"
|
|
)
|
|
|
|
type auditHandler struct {
|
|
svc *service.AuditService
|
|
}
|
|
|
|
func newAuditHandler(svc *service.AuditService) *auditHandler {
|
|
return &auditHandler{svc: svc}
|
|
}
|
|
|
|
type auditLogResponse struct {
|
|
ID string `json:"id"`
|
|
ActorType string `json:"actor_type"`
|
|
ActorID string `json:"actor_id,omitempty"`
|
|
ActorName string `json:"actor_name,omitempty"`
|
|
ResourceType string `json:"resource_type"`
|
|
ResourceID string `json:"resource_id,omitempty"`
|
|
Action string `json:"action"`
|
|
Scope string `json:"scope"`
|
|
Status string `json:"status"`
|
|
Metadata map[string]any `json:"metadata,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// List handles GET /v1/audit-logs.
|
|
// Query params:
|
|
// - before: RFC3339 timestamp cursor (exclusive); omit to start from latest
|
|
// - limit: page size, default 50, max 200
|
|
// - resource_type: filter by resource type (sandbox, snapshot, team, api_key, member, host)
|
|
// - action: filter by action verb
|
|
//
|
|
// Members see only team-scoped events; admins/owners see all.
|
|
func (h *auditHandler) List(w http.ResponseWriter, r *http.Request) {
|
|
ac := auth.MustFromContext(r.Context())
|
|
|
|
// Parse ?before cursor.
|
|
var before time.Time
|
|
if s := r.URL.Query().Get("before"); s != "" {
|
|
var err error
|
|
before, err = time.Parse(time.RFC3339, s)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "before must be an RFC3339 timestamp")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Parse ?limit.
|
|
limit := 50
|
|
if s := r.URL.Query().Get("limit"); s != "" {
|
|
n, err := strconv.Atoi(s)
|
|
if err != nil || n < 1 {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "limit must be a positive integer")
|
|
return
|
|
}
|
|
limit = n
|
|
}
|
|
|
|
// Parse ?before_id cursor (UUID).
|
|
var beforeID pgtype.UUID
|
|
if s := r.URL.Query().Get("before_id"); s != "" {
|
|
parsed, err := id.ParseAuditLogID(s)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "before_id must be a valid audit log ID")
|
|
return
|
|
}
|
|
beforeID = parsed
|
|
}
|
|
|
|
entries, err := h.svc.List(r.Context(), service.AuditListParams{
|
|
TeamID: ac.TeamID,
|
|
AdminScoped: ac.Role == "owner" || ac.Role == "admin",
|
|
ResourceTypes: parseMultiParam(r.URL.Query()["resource_type"]),
|
|
Actions: parseMultiParam(r.URL.Query()["action"]),
|
|
Before: before,
|
|
BeforeID: beforeID,
|
|
Limit: limit,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "db_error", "failed to list audit logs")
|
|
return
|
|
}
|
|
|
|
items := make([]auditLogResponse, len(entries))
|
|
for i, e := range entries {
|
|
items[i] = auditLogResponse{
|
|
ID: e.ID,
|
|
ActorType: e.ActorType,
|
|
ActorID: e.ActorID,
|
|
ActorName: e.ActorName,
|
|
ResourceType: e.ResourceType,
|
|
ResourceID: e.ResourceID,
|
|
Action: e.Action,
|
|
Scope: e.Scope,
|
|
Status: e.Status,
|
|
Metadata: e.Metadata,
|
|
CreatedAt: e.CreatedAt.UTC().Format(time.RFC3339),
|
|
}
|
|
}
|
|
|
|
resp := map[string]any{"items": items}
|
|
if len(items) > 0 {
|
|
last := entries[len(entries)-1]
|
|
resp["next_before"] = last.CreatedAt.UTC().Format(time.RFC3339)
|
|
resp["next_before_id"] = last.ID
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// parseMultiParam flattens repeated params and comma-separated values into a
|
|
// single deduplicated slice. Empty strings are dropped. Returns nil (no filter)
|
|
// when no values are present.
|
|
//
|
|
// Both ?resource_type=sandbox&resource_type=snapshot
|
|
// and ?resource_type=sandbox,snapshot are accepted.
|
|
func parseMultiParam(values []string) []string {
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
seen := make(map[string]struct{})
|
|
var out []string
|
|
for _, v := range values {
|
|
for _, part := range strings.Split(v, ",") {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[part]; !ok {
|
|
seen[part] = struct{}{}
|
|
out = append(out, part)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|