mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
* feat(branding): white-label — public /branding, admin write, shell theming app_settings branding.* keys (product name, logo/favicon URLs, accent color, docs/support links). Public GET /branding so login and shell theme pre-auth; admin writes via PUT /settings. useBranding() applies document.title, accent CSS var and favicon; BrandWordmark replaces hardcoded wordmarks; admin Platform settings gain a Branding form. MCP containr_branding_get + settings_update. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(invites): team invite links — admin create/revoke, public accept Admins mint single-use invite links (sha256-hashed token at rest, optional email binding, configurable TTL). Public endpoints let the accept page validate the link and register — working even while public signup is disabled. - migration + sqlc queries/model for user_invites - POST /admin/invites (returns one-time token+url), GET list, DELETE revoke - GET /auth/invites/:token public validation, POST /auth/accept-invite - CLI containr invites list|create|revoke; MCP invites tools - /auth/accept-invite page (expired/used/invalid states, bound-email lock) - Admin page Team invites section: create, copy link, revoke - OpenAPI + generated types + CLI guide Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(admin): impersonate user — 15-min token, audit-logged POST /admin/users/:id/impersonate mints a short-lived bearer token (impersonated_by claim) for support/debugging. Admin accounts cannot be impersonated; every mint is audit-logged with actor + target. - CLI: containr admin impersonate <user-id> (works via CONTAINR_TOKEN) - MCP: containr_admin_impersonate - Admin UI: per-user Impersonate action → token modal with usage hint - OpenAPI + generated types Verified live: token resolves to target, admin target 403, audit row. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(git): GitHub App manifest self-provisioning + app-level webhook Admins create the instance GitHub App in-browser instead of hand- setting GITHUB_APP_* env vars: - POST /admin/git/github-app/manifest builds the manifest + URL; the frontend auto-submits it to github.com - /git/github-app/callback page exchanges the code via POST /admin/git/github-app/convert; credentials persist in app_settings (precedence over env) - GET /admin/git/github-app reports configured/source - POST /api/git/github-app/webhook — single app-level push receiver, HMAC-verified, dispatches via extracted dispatchPushToServices - install-url + installation-token paths resolve settings→env - CLI: containr admin github-app status|manifest|convert; MCP tools - Git providers UI shows "Set up GitHub App" for admins when unconfigured Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat: activity feed — enriched audit events, global + per-project timelines audit_logs gains severity/category/label columns derived from resource+action at write time; legacy rows are classified on read and backfilled at boot in batches. - GET /activity: global feed scoped to the caller's visible projects (admins see all); severity/category/resource/project_id/since filters, page+limit - GET /projects/:id/activity: timeline correlated to the project's services, deployments, databases and the project record; access mirrors project read rules - Retention worker prunes rows older than AUDIT_RETENTION_DAYS (default 90, 0 disables), running once at boot then daily - project.create/delete now audited so timelines aren't empty - CLI `containr activity` with filter flags; MCP tools containr_activity_list + containr_project_activity - Frontend: /activity page (severity chips, icon per category) under Operate nav; project workspace gains an Activity view tab - OpenAPI + generated types + CLI docs Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
305 lines
9.1 KiB
Go
305 lines
9.1 KiB
Go
package api
|
|
|
|
import (
|
|
"containr/internal/database"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type AuditLog struct {
|
|
ID string `json:"id" db:"id"`
|
|
UserID string `json:"user_id" db:"user_id"`
|
|
UserEmail string `json:"user_email,omitempty" db:"user_email"`
|
|
Resource string `json:"resource" db:"resource"`
|
|
ResourceID string `json:"resource_id" db:"resource_id"`
|
|
Action string `json:"action" db:"action"`
|
|
Details string `json:"details" db:"details"`
|
|
IPAddress string `json:"ip_address" db:"ip_address"`
|
|
UserAgent string `json:"user_agent" db:"user_agent"`
|
|
Severity string `json:"severity" db:"severity"`
|
|
Category string `json:"category" db:"category"`
|
|
Label string `json:"label" db:"label"`
|
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
}
|
|
|
|
// auditVerbs maps action verbs to past-tense words for feed labels.
|
|
var auditVerbs = map[string]string{
|
|
"create": "created", "delete": "deleted", "update": "updated",
|
|
"deploy": "deployed", "clone": "cloned", "move": "moved",
|
|
"backup": "backed up", "restore": "restored", "revoke": "revoked",
|
|
"impersonate": "impersonated", "provision": "provisioned",
|
|
"accept": "accepted", "login": "logged in", "scale": "scaled",
|
|
"sleep": "put to sleep", "wake": "woke", "approve": "approved",
|
|
"set_admin": "admin flag changed", "provisioned": "provisioned",
|
|
}
|
|
|
|
// classifyAuditEvent derives severity/category/label for the activity feed.
|
|
// Actions may already carry the resource prefix ("service.clone") — it is
|
|
// stripped before matching.
|
|
func classifyAuditEvent(resource, action string) (severity, category, label string) {
|
|
verb := action
|
|
if idx := strings.LastIndex(verb, "."); idx >= 0 {
|
|
verb = verb[idx+1:]
|
|
}
|
|
|
|
category = resource
|
|
switch resource {
|
|
case "user_invite", "invite", "github_app", "banner", "settings":
|
|
category = "system"
|
|
case "user_token", "agent_token", "registry_credential":
|
|
category = "security"
|
|
}
|
|
|
|
severity = "info"
|
|
switch {
|
|
case strings.Contains(verb, "fail"), strings.Contains(verb, "error"), strings.Contains(verb, "denied"):
|
|
severity = "error"
|
|
case strings.Contains(verb, "delete"), strings.Contains(verb, "revoke"),
|
|
strings.Contains(verb, "remove"), strings.Contains(verb, "disable"),
|
|
strings.Contains(verb, "impersonate"):
|
|
severity = "warning"
|
|
case strings.Contains(verb, "create"), strings.Contains(verb, "success"),
|
|
strings.Contains(verb, "complete"), strings.Contains(verb, "accept"),
|
|
strings.Contains(verb, "provision"), strings.Contains(verb, "clone"),
|
|
strings.Contains(verb, "deploy"), strings.Contains(verb, "approve"):
|
|
severity = "success"
|
|
}
|
|
|
|
past := verb
|
|
if v, ok := auditVerbs[verb]; ok {
|
|
past = v
|
|
}
|
|
resName := strings.ReplaceAll(resource, "_", " ")
|
|
if resName != "" {
|
|
resName = strings.ToUpper(resName[:1]) + resName[1:]
|
|
}
|
|
label = resName + " " + past
|
|
return severity, category, label
|
|
}
|
|
|
|
type AuditLogDetail struct {
|
|
OldValue interface{} `json:"old_value,omitempty"`
|
|
NewValue interface{} `json:"new_value,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
}
|
|
|
|
func LogAudit(userID, resource, resourceID, action string, details map[string]interface{}) {
|
|
db := GetAuditDB()
|
|
if db == nil {
|
|
return
|
|
}
|
|
|
|
detailsJSON, _ := json.Marshal(details)
|
|
resourceUUID := parseUUIDOrNil(resourceID)
|
|
userUUID := parseUUIDOrNil(userID)
|
|
severity, category, label := classifyAuditEvent(resource, action)
|
|
|
|
auditID := uuid.New().String()
|
|
_, err := db.Exec(
|
|
`INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, severity, category, label, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
|
auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), severity, category, label, time.Now().UTC(),
|
|
)
|
|
|
|
if err != nil {
|
|
}
|
|
|
|
emitWebhookEvent(userID, resource, resourceID, action, details)
|
|
}
|
|
|
|
func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, details map[string]interface{}) {
|
|
userID, _ := c.Get("user_id")
|
|
|
|
if details == nil {
|
|
details = map[string]interface{}{}
|
|
}
|
|
details["ip_address"] = c.ClientIP()
|
|
details["user_agent"] = c.GetHeader("User-Agent")
|
|
|
|
detailsJSON, _ := json.Marshal(details)
|
|
|
|
db := c.MustGet("db").(*database.DB)
|
|
userIDStr := ""
|
|
if uid, ok := userID.(string); ok {
|
|
userIDStr = uid
|
|
}
|
|
userUUID := parseUUIDOrNil(userIDStr)
|
|
resourceUUID := parseUUIDOrNil(resourceID)
|
|
severity, category, label := classifyAuditEvent(resource, action)
|
|
|
|
auditID := uuid.New().String()
|
|
_, err := db.Exec(
|
|
`INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, ip_address, user_agent, severity, category, label, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $11, $12)`,
|
|
auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), c.ClientIP(), c.GetHeader("User-Agent"), severity, category, label, time.Now().UTC(),
|
|
)
|
|
|
|
if err != nil {
|
|
}
|
|
|
|
emitWebhookEvent(userIDStr, resource, resourceID, action, details)
|
|
}
|
|
|
|
var auditDB *database.DB
|
|
|
|
func GetAuditDB() *database.DB {
|
|
return auditDB
|
|
}
|
|
|
|
func SetAuditDB(db *database.DB) {
|
|
auditDB = db
|
|
}
|
|
|
|
func handleGetAuditLogs(c *gin.Context) {
|
|
db := c.MustGet("db").(*database.DB)
|
|
resource := strings.TrimSpace(c.Query("resource"))
|
|
action := strings.TrimSpace(c.Query("action"))
|
|
actor := strings.TrimSpace(c.Query("actor"))
|
|
userIDFilter := strings.TrimSpace(c.Query("user_id"))
|
|
since := strings.TrimSpace(c.Query("since"))
|
|
page := parsePositiveInt(c.DefaultQuery("page", "1"), 1)
|
|
limit := parsePositiveInt(c.DefaultQuery("limit", "50"), 50)
|
|
if limit > 500 {
|
|
limit = 500
|
|
}
|
|
offset := (page - 1) * limit
|
|
|
|
conditions := []string{"TRUE"}
|
|
args := []interface{}{}
|
|
nextArg := 1
|
|
|
|
if userIDFilter != "" {
|
|
conditions = append(conditions, fmt.Sprintf("a.user_id = $%d::uuid", nextArg))
|
|
args = append(args, userIDFilter)
|
|
nextArg++
|
|
}
|
|
if actor != "" {
|
|
conditions = append(conditions, fmt.Sprintf("u.email ILIKE $%d", nextArg))
|
|
args = append(args, "%"+actor+"%")
|
|
nextArg++
|
|
}
|
|
if resource != "" {
|
|
conditions = append(conditions, fmt.Sprintf("a.resource = $%d", nextArg))
|
|
args = append(args, resource)
|
|
nextArg++
|
|
}
|
|
if action != "" {
|
|
conditions = append(conditions, fmt.Sprintf("a.action = $%d", nextArg))
|
|
args = append(args, action)
|
|
nextArg++
|
|
}
|
|
if since != "" {
|
|
sinceTime, err := time.Parse(time.RFC3339, since)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid since timestamp, expected RFC3339"})
|
|
return
|
|
}
|
|
conditions = append(conditions, fmt.Sprintf("a.created_at >= $%d", nextArg))
|
|
args = append(args, sinceTime)
|
|
nextArg++
|
|
}
|
|
|
|
whereClause := strings.Join(conditions, " AND ")
|
|
query := fmt.Sprintf(`SELECT
|
|
a.id,
|
|
COALESCE(a.user_id::text, ''),
|
|
COALESCE(u.email, ''),
|
|
a.resource,
|
|
COALESCE(a.resource_id::text, ''),
|
|
a.action,
|
|
COALESCE(a.details::text, '{}'),
|
|
COALESCE(a.ip_address::text, ''),
|
|
COALESCE(a.user_agent, ''),
|
|
COALESCE(a.severity, 'info'),
|
|
COALESCE(a.category, ''),
|
|
COALESCE(a.label, ''),
|
|
a.created_at
|
|
FROM audit_logs a
|
|
LEFT JOIN users u ON u.id = a.user_id
|
|
WHERE %s
|
|
ORDER BY a.created_at DESC
|
|
LIMIT $%d OFFSET $%d`, whereClause, nextArg, nextArg+1)
|
|
args = append(args, limit, offset)
|
|
|
|
rows, err := db.Query(query, args...)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch audit logs"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var logs []AuditLog
|
|
for rows.Next() {
|
|
var log AuditLog
|
|
err := rows.Scan(&log.ID, &log.UserID, &log.UserEmail, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.Severity, &log.Category, &log.Label, &log.CreatedAt)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
logs = append(logs, log)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"audit_logs": logs})
|
|
}
|
|
|
|
func handleGetResourceAuditLogs(c *gin.Context) {
|
|
db := c.MustGet("db").(*database.DB)
|
|
userID := c.MustGet("user_id").(string)
|
|
resource := c.Param("resource")
|
|
resourceID := c.Param("id")
|
|
|
|
rows, err := db.Query(
|
|
`SELECT id, COALESCE(user_id::text, ''), resource, COALESCE(resource_id::text, ''), action, COALESCE(details::text, '{}'),
|
|
COALESCE(ip_address::text, ''), COALESCE(user_agent, ''), created_at
|
|
FROM audit_logs
|
|
WHERE user_id::text = $1 AND resource = $2 AND resource_id::text = $3
|
|
ORDER BY created_at DESC
|
|
LIMIT 100`,
|
|
userID, resource, resourceID,
|
|
)
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch audit logs"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var logs []AuditLog
|
|
for rows.Next() {
|
|
var log AuditLog
|
|
err := rows.Scan(&log.ID, &log.UserID, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.CreatedAt)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
logs = append(logs, log)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"audit_logs": logs})
|
|
}
|
|
|
|
func parsePositiveInt(raw string, fallback int) int {
|
|
v, err := strconv.Atoi(strings.TrimSpace(raw))
|
|
if err != nil || v <= 0 {
|
|
return fallback
|
|
}
|
|
return v
|
|
}
|
|
|
|
func parseUUIDOrNil(raw string) interface{} {
|
|
trimmed := strings.TrimSpace(raw)
|
|
if trimmed == "" {
|
|
return nil
|
|
}
|
|
if _, err := uuid.Parse(trimmed); err != nil {
|
|
return nil
|
|
}
|
|
return trimmed
|
|
}
|