diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3c2f2337..4ee9719f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -202,6 +202,7 @@ details intentionally omit users, environment UUIDs, query names, and payloads. - Per-environment access lives in `user_permissions`. - The user manager's `CheckPermissions(...)` method grants access by environment UUID/string plus level (`user`, `query`, `carve`, `admin`). - Full admins bypass per-environment checks. +- HTTP handlers use `CheckPermissionsContext` with a request-scoped cache. Authentication seeds the user row; each environment's permission rows are loaded once per request. Streams create a fresh snapshot for every batch and heartbeat, preserving database-authoritative revocation across replicas and CLI changes. No permission snapshot is reused between requests or stream batches. ## Settings Subsystem @@ -285,7 +286,10 @@ Handled by `pkg/auditlog`. - Model: `AuditLog` - GORM table: `audit_logs` -- Created from API and hosted MCP actions such as login, node/query/carve actions, settings changes, and MCP tool calls. TLS also records failed enrollment and consumed service-control events. +- Created from API and hosted MCP actions such as login, node/query/carve mutations, explicit downloads, settings changes, and MCP tool calls. Routine successful REST reads and polling do not create audit rows. TLS records successful and failed enrollment and consumed service-control events. +- Successful enrollment/re-enrollment uses `LogTypeEnroll` (12), after node persistence succeeds. The audit-backed environment activity endpoint counts only that type as enrollment; generic node events, including historical viewed rows, are excluded. Query actions use `LogTypeQuery`. Earlier successful enrollments were not audited, so historical enrollment counts are not reconstructed from generic node events. +- The API seeds the global `api/audit_log_retention_days` integer setting (90 days; valid range 1–36500). It can be edited through the settings API. If older audit history must be retained on upgrade, preconfigure its global `setting_values` row before starting the upgraded API. At startup and hourly, the API physically deletes expired rows from all services, including soft-deleted rows, in batches of 1,000, with a 100 ms pause and five-minute sweep limit. Pruning runs even if new audit writes are disabled. Invalid settings or setting-read failures stop cleanup. API replicas may safely race; no cleanup worker runs when the API is stopped. +- DB log startup builds `(uuid, created_at)` indexes before removing the redundant status/result UUID indexes. PostgreSQL replacement indexes must be valid and not building before removal; drops use `CONCURRENTLY`. The UUID column retains its original `varchar(191)` type on MySQL and `text` type on PostgreSQL/SQLite, avoiding a column rewrite. The query-log UUID index stays because `(name, created_at)` does not cover it. Complete rollout of the new logger code before relying on removal: an older process can recreate the old indexes at startup. ## Request Flow diff --git a/cmd/api/auth.go b/cmd/api/auth.go index 320a2ccd..916a32f2 100644 --- a/cmd/api/auth.go +++ b/cmd/api/auth.go @@ -10,6 +10,7 @@ import ( "github.com/jmpsec/osctrl/cmd/api/handlers" "github.com/jmpsec/osctrl/pkg/config" "github.com/jmpsec/osctrl/pkg/types" + "github.com/jmpsec/osctrl/pkg/users" "github.com/jmpsec/osctrl/pkg/utils" "github.com/rs/zerolog/log" ) @@ -102,7 +103,7 @@ func handlerAuthCheck(h http.Handler, auth, jwtSecret string) http.Handler { // Set middleware values s := make(handlers.ContextValue) s["user"] = "admin" - ctx := context.WithValue(r.Context(), handlers.ContextKey(contextAPI), s) + ctx := users.WithPermissionCache(context.WithValue(r.Context(), handlers.ContextKey(contextAPI), s)) // Access granted h.ServeHTTP(w, r.WithContext(ctx)) case config.AuthJWT: @@ -163,7 +164,8 @@ func handlerAuthCheck(h http.Handler, auth, jwtSecret string) http.Handler { // Set middleware values s := make(handlers.ContextValue) s["user"] = claims.Username - ctx := context.WithValue(r.Context(), handlers.ContextKey(contextAPI), s) + ctx := users.WithPermissionCache(context.WithValue(r.Context(), handlers.ContextKey(contextAPI), s)) + users.CacheAuthenticatedUser(ctx, user) // Access granted h.ServeHTTP(w, r.WithContext(ctx)) } @@ -186,5 +188,9 @@ func eventSessionValid(r *http.Request, auth, secret string) bool { } user, err := apiUsers.Get(claims.Username) ctx, ok := r.Context().Value(handlers.ContextKey(contextAPI)).(handlers.ContextValue) - return ok && ctx["user"] == claims.Username && err == nil && user.APIToken != "" && subtle.ConstantTimeCompare([]byte(user.APIToken), []byte(token)) == 1 + valid = ok && ctx["user"] == claims.Username && err == nil && user.APIToken != "" && subtle.ConstantTimeCompare([]byte(user.APIToken), []byte(token)) == 1 + if valid { + users.CacheAuthenticatedUser(r.Context(), user) + } + return valid } diff --git a/cmd/api/events_auth_test.go b/cmd/api/events_auth_test.go index 85c5c185..c6f9d6a0 100644 --- a/cmd/api/events_auth_test.go +++ b/cmd/api/events_auth_test.go @@ -50,3 +50,32 @@ func TestEventSessionRechecksStoredTokenAndIdentity(t *testing.T) { r.Header.Set("Cookie", cookieNameToken+"="+expired) require.False(t, eventSessionValid(r, config.AuthJWT, "event-test-secret-at-least-thirty-two-bytes")) } + +func TestEventPermissionSnapshotReusesAuthenticatedUser(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + previous := apiUsers + t.Cleanup(func() { apiUsers = previous }) + secret := "event-test-secret-at-least-thirty-two-bytes" + apiUsers = users.CreateUserManager(db).WithJWT(&config.YAMLConfigurationJWT{JWTSecret: secret, HoursToExpire: 1}) + require.NoError(t, apiUsers.Create(users.AdminUser{Username: "alice"})) + require.NoError(t, apiUsers.CreatePermission(users.UserPermission{Username: "alice", Environment: "env", AccessType: int(users.AdminLevel), AccessValue: true})) + token, expiry, err := apiUsers.CreateToken("alice", "api", 1) + require.NoError(t, err) + require.NoError(t, apiUsers.UpdateToken("alice", token, expiry)) + r := httptest.NewRequest("GET", "/api/v1/events", nil) + r.Header.Set("Authorization", "Bearer "+token) + r = r.WithContext(context.WithValue(r.Context(), handlers.ContextKey(contextAPI), handlers.ContextValue{"user": "alice"})) + queries := 0 + require.NoError(t, db.Callback().Query().Before("gorm:query").Register("count_auth_reads", func(*gorm.DB) { queries++ })) + pass := r.WithContext(users.WithPermissionCache(r.Context())) + require.True(t, eventSessionValid(pass, config.AuthJWT, secret)) + require.True(t, apiUsers.CheckPermissionsContext(pass.Context(), "alice", users.QueryLevel, "env")) + require.True(t, apiUsers.CheckPermissionsContext(pass.Context(), "alice", users.CarveLevel, "env")) + require.Equal(t, 2, queries, "one token/user read plus one permissions read for both topics") + require.NoError(t, db.Model(&users.UserPermission{}).Where("username = ?", "alice").Update("access_value", false).Error) + pass = r.WithContext(users.WithPermissionCache(r.Context())) + require.True(t, eventSessionValid(pass, config.AuthJWT, secret)) + require.False(t, apiUsers.CheckPermissionsContext(pass.Context(), "alice", users.QueryLevel, "env")) + require.Equal(t, 4, queries, "the next batch must refresh both rows") +} diff --git a/cmd/api/handlers/alerts.go b/cmd/api/handlers/alerts.go index 46132a17..dd8c14a8 100644 --- a/cmd/api/handlers/alerts.go +++ b/cmd/api/handlers/alerts.go @@ -10,7 +10,6 @@ import ( "time" "github.com/jmpsec/osctrl/pkg/alerts" - "github.com/jmpsec/osctrl/pkg/auditlog" "github.com/jmpsec/osctrl/pkg/config" "github.com/jmpsec/osctrl/pkg/servicecommands" "github.com/jmpsec/osctrl/pkg/types" @@ -100,7 +99,7 @@ func toAlertChannelDTO(c alerts.AlertChannel, reveal bool) alertChannelDTO { func (h *HandlersApi) requireAlertsAdmin(w http.ResponseWriter, r *http.Request) (string, bool) { ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use alerts API by user %s", ctx[ctxUser])) return "", false } @@ -134,7 +133,7 @@ func (h *HandlersApi) AlertRulesListHandler(w http.ResponseWriter, r *http.Reque if h.DebugHTTPConfig != nil && h.DebugHTTPConfig.EnableHTTP { utils.DebugHTTPDump(h.DebugHTTP, r, false) } - user, ok := h.requireAlertsAdmin(w, r) + _, ok := h.requireAlertsAdmin(w, r) if !ok { return } @@ -156,7 +155,6 @@ func (h *HandlersApi) AlertRulesListHandler(w http.ResponseWriter, r *http.Reque for _, row := range rows { out = append(out, toAlertRuleDTO(row)) } - h.AuditLog.Visit(user, r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, out) } @@ -179,7 +177,7 @@ func (h *HandlersApi) AlertRuleGetHandler(w http.ResponseWriter, r *http.Request if h.DebugHTTPConfig != nil && h.DebugHTTPConfig.EnableHTTP { utils.DebugHTTPDump(h.DebugHTTP, r, false) } - user, ok := h.requireAlertsAdmin(w, r) + _, ok := h.requireAlertsAdmin(w, r) if !ok { return } @@ -197,7 +195,6 @@ func (h *HandlersApi) AlertRuleGetHandler(w http.ResponseWriter, r *http.Request respondAlertsErr(w, err) return } - h.AuditLog.Visit(user, r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, toAlertRuleDTO(row)) } @@ -377,7 +374,7 @@ func (h *HandlersApi) AlertChannelsListHandler(w http.ResponseWriter, r *http.Re if h.DebugHTTPConfig != nil && h.DebugHTTPConfig.EnableHTTP { utils.DebugHTTPDump(h.DebugHTTP, r, false) } - user, ok := h.requireAlertsAdmin(w, r) + _, ok := h.requireAlertsAdmin(w, r) if !ok { return } @@ -400,7 +397,6 @@ func (h *HandlersApi) AlertChannelsListHandler(w http.ResponseWriter, r *http.Re for _, row := range rows { out = append(out, toAlertChannelDTO(row, reveal)) } - h.AuditLog.Visit(user, r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, out) } @@ -468,7 +464,7 @@ func (h *HandlersApi) AlertChannelsGetHandler(w http.ResponseWriter, r *http.Req if h.DebugHTTPConfig != nil && h.DebugHTTPConfig.EnableHTTP { utils.DebugHTTPDump(h.DebugHTTP, r, false) } - user, ok := h.requireAlertsAdmin(w, r) + _, ok := h.requireAlertsAdmin(w, r) if !ok { return } @@ -486,7 +482,6 @@ func (h *HandlersApi) AlertChannelsGetHandler(w http.ResponseWriter, r *http.Req respondAlertsErr(w, err) return } - h.AuditLog.Visit(user, r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, toAlertChannelDTO(row, parseReveal(r))) } @@ -737,7 +732,7 @@ func (h *HandlersApi) AlertHistoryHandler(w http.ResponseWriter, r *http.Request if h.DebugHTTPConfig != nil && h.DebugHTTPConfig.EnableHTTP { utils.DebugHTTPDump(h.DebugHTTP, r, false) } - user, ok := h.requireAlertsAdmin(w, r) + _, ok := h.requireAlertsAdmin(w, r) if !ok { return } @@ -751,7 +746,6 @@ func (h *HandlersApi) AlertHistoryHandler(w http.ResponseWriter, r *http.Request apiErrorResponse(w, "error listing alert history", http.StatusInternalServerError, err) return } - h.AuditLog.Visit(user, r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, rows) } diff --git a/cmd/api/handlers/audit.go b/cmd/api/handlers/audit.go index 9a43fe78..c4aeeaf2 100644 --- a/cmd/api/handlers/audit.go +++ b/cmd/api/handlers/audit.go @@ -18,15 +18,14 @@ import ( // // ?service=... exact match on service name // ?username=... case-insensitive partial match on username -// ?type=... log type integer (1..11), see pkg/auditlog.LogType* +// ?type=... log type integer (1..12), see pkg/auditlog.LogType* // ?env_uuid=... filter to one environment (resolved to internal ID) // ?since=RFC3339 created_at >= since // ?until=RFC3339 created_at <= until // ?page=N 1-indexed page; default 1 // ?page_size=N default 50, max 500 // -// Returns the SPA-canonical paginated envelope. The handler audit-logs the -// visit on success. +// Returns the SPA-canonical paginated envelope without auditing reads. // @Summary List audit logs // @Description Returns paginated API audit log entries. // @Tags audit diff --git a/cmd/api/handlers/audit_growth_test.go b/cmd/api/handlers/audit_growth_test.go new file mode 100644 index 00000000..fef5d497 --- /dev/null +++ b/cmd/api/handlers/audit_growth_test.go @@ -0,0 +1,61 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/jmpsec/osctrl/pkg/auditlog" + "github.com/jmpsec/osctrl/pkg/config" + "github.com/stretchr/testify/require" +) + +func TestPollingDoesNotCreateAuditRows(t *testing.T) { + db, h, env, _ := setupConsoleHandlers(t) + h.DebugHTTPConfig = &config.YAMLConfigurationDebug{} + var err error + h.AuditLog, err = auditlog.CreateAuditLogManager(db, config.ServiceAPI, true) + require.NoError(t, err) + for _, handler := range []http.HandlerFunc{h.NodesPagedHandler, h.QueryListHandler, h.CarveListHandler} { + for i := 0; i < 3; i++ { + r := consoleRequest(http.MethodGet, "/poll", nil, "alice") + r.SetPathValue("env", env.Name) + r.SetPathValue("target", "all") + w := httptest.NewRecorder() + handler(w, r) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + } + } + var count int64 + require.NoError(t, db.Model(&auditlog.AuditLog{}).Count(&count).Error) + require.Zero(t, count) +} + +func TestEnvActivityCountsOnlySuccessfulEnrollments(t *testing.T) { + db, h, env, _ := setupConsoleHandlers(t) + var err error + h.AuditLog, err = auditlog.CreateAuditLogManager(db, config.ServiceAPI, true) + require.NoError(t, err) + h.AuditLog.NodeAction("alice", "viewed paged nodes", "", env.ID) + h.AuditLog.NodeAction("alice", "deleted node", "", env.ID) + h.AuditLog.FailedEnroll("", env.Name, "invalid secret", env.ID) + h.AuditLog.Denied("alice", "/nodes", "", "forbidden", auditlog.LogTypeNode, env.ID) + h.AuditLog.QueryAction("alice", "complete query", "", env.ID) + h.AuditLog.NewEnroll("NODE", "", env.ID) + h.AuditLog.NewEnroll("OTHER-ENV", "", env.ID+1) + r := consoleRequest(http.MethodGet, "/activity", nil, "alice") + r.SetPathValue("env", env.Name) + w := httptest.NewRecorder() + h.EnvActivityHandler(w, r) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var buckets []ActivityBucket + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &buckets)) + var enrolls, queries int + for _, bucket := range buckets { + enrolls += bucket.Enroll + queries += bucket.Query + } + require.Equal(t, 1, enrolls) + require.Equal(t, 1, queries) +} diff --git a/cmd/api/handlers/auth_providers.go b/cmd/api/handlers/auth_providers.go index dea261ae..6bf290a2 100644 --- a/cmd/api/handlers/auth_providers.go +++ b/cmd/api/handlers/auth_providers.go @@ -12,7 +12,6 @@ import ( "strings" "time" - "github.com/jmpsec/osctrl/pkg/auditlog" "github.com/jmpsec/osctrl/pkg/authproviders" "github.com/jmpsec/osctrl/pkg/config" "github.com/jmpsec/osctrl/pkg/servicecommands" @@ -57,7 +56,7 @@ func toAuthProviderDTO(p authproviders.AuthProvider, reveal bool) authProviderDT func (h *HandlersApi) requireAuthProvidersAdmin(w http.ResponseWriter, r *http.Request) (string, bool) { ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use auth-providers API by user %s", ctx[ctxUser])) return "", false } @@ -69,7 +68,7 @@ func (h *HandlersApi) AuthProvidersListHandler(w http.ResponseWriter, r *http.Re if h.DebugHTTPConfig != nil && h.DebugHTTPConfig.EnableHTTP { utils.DebugHTTPDump(h.DebugHTTP, r, false) } - user, ok := h.requireAuthProvidersAdmin(w, r) + _, ok := h.requireAuthProvidersAdmin(w, r) if !ok { return } @@ -87,7 +86,6 @@ func (h *HandlersApi) AuthProvidersListHandler(w http.ResponseWriter, r *http.Re for _, row := range rows { out = append(out, toAuthProviderDTO(row, reveal)) } - h.AuditLog.Visit(user, r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, out) } @@ -125,7 +123,7 @@ func (h *HandlersApi) AuthProvidersGetHandler(w http.ResponseWriter, r *http.Req if h.DebugHTTPConfig != nil && h.DebugHTTPConfig.EnableHTTP { utils.DebugHTTPDump(h.DebugHTTP, r, false) } - user, ok := h.requireAuthProvidersAdmin(w, r) + _, ok := h.requireAuthProvidersAdmin(w, r) if !ok { return } @@ -147,7 +145,6 @@ func (h *HandlersApi) AuthProvidersGetHandler(w http.ResponseWriter, r *http.Req apiErrorResponse(w, "error getting auth provider", http.StatusInternalServerError, err) return } - h.AuditLog.Visit(user, r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, toAuthProviderDTO(row, r.URL.Query().Get("reveal") == "1")) } diff --git a/cmd/api/handlers/carves.go b/cmd/api/handlers/carves.go index fca9d77d..f05173a1 100644 --- a/cmd/api/handlers/carves.go +++ b/cmd/api/handlers/carves.go @@ -111,7 +111,7 @@ func (h *HandlersApi) CarveShowHandler(w http.ResponseWriter, r *http.Request) { return } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.CarveLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.CarveLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -163,7 +163,6 @@ func (h *HandlersApi) CarveShowHandler(w http.ResponseWriter, r *http.Request) { Files: views, } log.Debug().Msgf("Returned carve %s (%d files)", name, len(views)) - h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, resp) } @@ -203,7 +202,7 @@ func (h *HandlersApi) CarveQueriesHandler(w http.ResponseWriter, r *http.Request return } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.CarveLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.CarveLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -222,7 +221,6 @@ func (h *HandlersApi) CarveQueriesHandler(w http.ResponseWriter, r *http.Request return } log.Debug().Msgf("Returned %d carves", len(carvesList)) - h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, carvesList) } @@ -266,7 +264,7 @@ func (h *HandlersApi) CarveListHandler(w http.ResponseWriter, r *http.Request) { return } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.CarveLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.CarveLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -325,7 +323,6 @@ func (h *HandlersApi) CarveListHandler(w http.ResponseWriter, r *http.Request) { TotalPages: totalPages, } log.Debug().Msgf("Returned %d carves (page %d of %d)", len(items), page, totalPages) - h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, resp) } @@ -363,7 +360,7 @@ func (h *HandlersApi) CarvesRunHandler(w http.ResponseWriter, r *http.Request) { return } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.CarveLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.CarveLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -381,7 +378,7 @@ func (h *HandlersApi) CarvesRunHandler(w http.ResponseWriter, r *http.Request) { // allowlist gate here so legitimate paths containing spaces or // non-ASCII characters round-trip correctly. for _, e := range c.Environments { - if !h.Users.CheckPermissions(ctx[ctxUser], users.CarveLevel, e) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.CarveLevel, e) { apiErrorResponse(w, fmt.Sprintf("%s has insufficient permissions to run carves in environment %s", ctx[ctxUser], e), http.StatusForbidden, nil) return } @@ -486,7 +483,7 @@ func (h *HandlersApi) CarvesActionHandler(w http.ResponseWriter, r *http.Request return } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -585,7 +582,7 @@ func (h *HandlersApi) CarveArchiveHandler(w http.ResponseWriter, r *http.Request return } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.CarveLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.CarveLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } diff --git a/cmd/api/handlers/console.go b/cmd/api/handlers/console.go index 3954ec6a..f88d4988 100644 --- a/cmd/api/handlers/console.go +++ b/cmd/api/handlers/console.go @@ -92,7 +92,9 @@ func (h *HandlersApi) ConsoleSessionCreateHandler(w http.ResponseWriter, r *http if primingCmd, primingErr := h.Console.SubmitPrimingCommand(session.ID, h.consolePrimingTimeout(env)); primingErr == nil { priming = &primingCmd } - h.auditConsoleVisit(ctx[ctxUser], r, env.ID) + if h.AuditLog != nil { + h.AuditLog.QueryAction(ctx[ctxUser], fmt.Sprintf("created console session %d", session.ID), strings.Split(r.RemoteAddr, ":")[0], env.ID) + } utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusCreated, consoleSessionResponse{ Session: session, History: history, @@ -102,7 +104,7 @@ func (h *HandlersApi) ConsoleSessionCreateHandler(w http.ResponseWriter, r *http } func (h *HandlersApi) ConsoleSessionShowHandler(w http.ResponseWriter, r *http.Request) { - env, ctx, session, ok := h.consoleSessionContext(w, r) + _, _, session, ok := h.consoleSessionContext(w, r) if !ok { return } @@ -111,7 +113,6 @@ func (h *HandlersApi) ConsoleSessionShowHandler(w http.ResponseWriter, r *http.R apiErrorResponse(w, "error refreshing console session", http.StatusInternalServerError, err) return } - h.auditConsoleVisit(ctx[ctxUser], r, env.ID) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, session) } @@ -142,7 +143,7 @@ func (h *HandlersApi) ConsoleCommandCreateHandler(w http.ResponseWriter, r *http apiErrorResponse(w, err.Error(), http.StatusBadRequest, err) return } - if preview.Kind == console.CommandCarve && !h.Users.CheckPermissions(ctx[ctxUser], users.CarveLevel, env.UUID) { + if preview.Kind == console.CommandCarve && !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.CarveLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use console get by user %s", ctx[ctxUser])) return } @@ -375,7 +376,7 @@ func (h *HandlersApi) consoleEnvContext(w http.ResponseWriter, r *http.Request) return environments.TLSEnvironment{}, nil, false } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return environments.TLSEnvironment{}, nil, false } @@ -424,9 +425,3 @@ func consoleNotFoundOrError(w http.ResponseWriter, notFoundMsg, errorMsg string, } apiErrorResponse(w, errorMsg, http.StatusInternalServerError, err) } - -func (h *HandlersApi) auditConsoleVisit(user string, r *http.Request, envID uint) { - if h.AuditLog != nil { - h.AuditLog.Visit(user, r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], envID) - } -} diff --git a/cmd/api/handlers/environments.go b/cmd/api/handlers/environments.go index dc77b5fb..fe98b081 100644 --- a/cmd/api/handlers/environments.go +++ b/cmd/api/handlers/environments.go @@ -106,7 +106,7 @@ func (h *HandlersApi) EnvironmentHandler(w http.ResponseWriter, r *http.Request) } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.UserLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.UserLevel, env.UUID) { h.denyEnv(w, r, ctx, env.ID, "permission check failed") return } @@ -114,8 +114,7 @@ func (h *HandlersApi) EnvironmentHandler(w http.ResponseWriter, r *http.Request) // super-admins) receive the full storage struct including secret / // certificate / flags. UserLevel operators receive the low-privilege // view that omits enroll credentials. - h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) - if h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + if h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, env.UUID) { log.Debug().Msgf("Returned environment %s (admin view)", env.Name) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, env) return @@ -159,7 +158,7 @@ func (h *HandlersApi) EnvironmentMapHandler(w http.ResponseWriter, r *http.Reque } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { h.denyEnv(w, r, ctx, auditlog.NoEnvironment, "permission check failed") return } @@ -180,7 +179,6 @@ func (h *HandlersApi) EnvironmentMapHandler(w http.ResponseWriter, r *http.Reque } // Serialize and serve JSON log.Debug().Msg("Returned environments map") - h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, envMap) } @@ -242,7 +240,6 @@ func (h *HandlersApi) EnvironmentsHandler(w http.ResponseWriter, r *http.Request out = []any{} } log.Debug().Msgf("Returned %d environment(s) to %s", len(out), requester) - h.AuditLog.Visit(requester, r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, out) } @@ -292,7 +289,7 @@ func (h *HandlersApi) EnvEnrollHandler(w http.ResponseWriter, r *http.Request) { // so it must be gated to AdminLevel on the env, not UserLevel. // ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, env.UUID) { h.denyEnv(w, r, ctx, env.ID, "permission check failed") return } @@ -337,7 +334,6 @@ func (h *HandlersApi) EnvEnrollHandler(w http.ResponseWriter, r *http.Request) { // Serialize and serve JSON. Don't log the payload — it contains the // enroll secret. log.Debug().Msgf("Returned enroll data for environment %s target=%s", env.Name, targetVar) - h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.ApiDataResponse{Data: returnData}) } @@ -384,7 +380,7 @@ func (h *HandlersApi) EnvRemoveHandler(w http.ResponseWriter, r *http.Request) { // remove-secret in the URL, so the endpoint must be AdminLevel-gated // just like the enroll variant. ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, env.UUID) { h.denyEnv(w, r, ctx, env.ID, "permission check failed") return } @@ -415,7 +411,6 @@ func (h *HandlersApi) EnvRemoveHandler(w http.ResponseWriter, r *http.Request) { // Serialize and serve JSON. Don't log the payload — it embeds the // remove secret. log.Debug().Msgf("Returned remove data for environment %s target=%s", env.Name, targetVar) - h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.ApiDataResponse{Data: returnData}) } @@ -462,7 +457,7 @@ func (h *HandlersApi) EnvEnrollActionsHandler(w http.ResponseWriter, r *http.Req } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, env.UUID) { h.denyEnv(w, r, ctx, env.ID, "permission check failed") return } @@ -582,7 +577,7 @@ func (h *HandlersApi) EnvRemoveActionsHandler(w http.ResponseWriter, r *http.Req } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, env.UUID) { h.denyEnv(w, r, ctx, env.ID, "permission check failed") return } @@ -659,7 +654,7 @@ func (h *HandlersApi) EnvActionsHandler(w http.ResponseWriter, r *http.Request) } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { h.denyEnv(w, r, ctx, auditlog.NoEnvironment, "permission check failed") return } @@ -842,7 +837,7 @@ func (h *HandlersApi) EnvConfigurationHandler(w http.ResponseWriter, r *http.Req return } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, env.UUID) { h.denyEnv(w, r, ctx, env.ID, "permission check failed") return } @@ -881,7 +876,7 @@ func (h *HandlersApi) EnvCertUploadHandler(w http.ResponseWriter, r *http.Reques return } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, env.UUID) { h.denyEnv(w, r, ctx, env.ID, "permission check failed") return } diff --git a/cmd/api/handlers/environments_crud.go b/cmd/api/handlers/environments_crud.go index cebd6e65..8353b35c 100644 --- a/cmd/api/handlers/environments_crud.go +++ b/cmd/api/handlers/environments_crud.go @@ -44,7 +44,7 @@ func (h *HandlersApi) EnvironmentCreateHandler(w http.ResponseWriter, r *http.Re utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { h.denyEnv(w, r, ctx, auditlog.NoEnvironment, "permission check failed") return } @@ -150,7 +150,7 @@ func (h *HandlersApi) EnvironmentUpdateHandler(w http.ResponseWriter, r *http.Re utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { h.denyEnv(w, r, ctx, auditlog.NoEnvironment, "permission check failed") return } @@ -284,7 +284,7 @@ func (h *HandlersApi) EnvironmentDeleteHandler(w http.ResponseWriter, r *http.Re utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { h.denyEnv(w, r, ctx, auditlog.NoEnvironment, "permission check failed") return } @@ -351,7 +351,7 @@ func (h *HandlersApi) EnvironmentConfigHandler(w http.ResponseWriter, r *http.Re return } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, env.UUID) { h.denyEnv(w, r, ctx, env.ID, "permission check failed") return } @@ -363,7 +363,6 @@ func (h *HandlersApi) EnvironmentConfigHandler(w http.ResponseWriter, r *http.Re ATC: env.ATC, Flags: env.Flags, } - h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, resp) } @@ -409,7 +408,7 @@ func (h *HandlersApi) EnvironmentConfigPatchHandler(w http.ResponseWriter, r *ht return } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, env.UUID) { h.denyEnv(w, r, ctx, env.ID, "permission check failed") return } @@ -561,7 +560,7 @@ func (h *HandlersApi) EnvironmentIntervalsPatchHandler(w http.ResponseWriter, r return } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, env.UUID) { h.denyEnv(w, r, ctx, env.ID, "permission check failed") return } @@ -649,7 +648,7 @@ func (h *HandlersApi) EnvironmentExpirationPatchHandler(w http.ResponseWriter, r return } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, env.UUID) { h.denyEnv(w, r, ctx, env.ID, "permission check failed") return } diff --git a/cmd/api/handlers/environments_inactivity.go b/cmd/api/handlers/environments_inactivity.go index 0d8b6191..7f6e5fb7 100644 --- a/cmd/api/handlers/environments_inactivity.go +++ b/cmd/api/handlers/environments_inactivity.go @@ -94,7 +94,7 @@ func (h *HandlersApi) inactivityEnvironment(w http.ResponseWriter, r *http.Reque apiErrorResponse(w, "error getting environment", status, err) return env, ctx, false } - if !h.Users.CheckPermissions(ctx[ctxUser], level, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], level, env.UUID) { h.denyEnv(w, r, ctx, env.ID, "permission check failed") return env, ctx, false } diff --git a/cmd/api/handlers/environments_packages.go b/cmd/api/handlers/environments_packages.go index fe9cb91c..a3b0f7cd 100644 --- a/cmd/api/handlers/environments_packages.go +++ b/cmd/api/handlers/environments_packages.go @@ -57,7 +57,7 @@ func (h *HandlersApi) EnvPackagesHandler(w http.ResponseWriter, r *http.Request) return } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -67,7 +67,6 @@ func (h *HandlersApi) EnvPackagesHandler(w http.ResponseWriter, r *http.Request) return } log.Debug().Msgf("Returned %d packages for env %s", len(pkgs), env.Name) - h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, pkgs) } @@ -107,7 +106,7 @@ func (h *HandlersApi) EnvPackageAddHandler(w http.ResponseWriter, r *http.Reques return } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -173,7 +172,7 @@ func (h *HandlersApi) EnvPackageRemoveHandler(w http.ResponseWriter, r *http.Req return } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -236,7 +235,7 @@ func (h *HandlersApi) EnvPackageUpdateHandler(w http.ResponseWriter, r *http.Req return } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } diff --git a/cmd/api/handlers/events.go b/cmd/api/handlers/events.go index 42c29b89..1b7226b6 100644 --- a/cmd/api/handlers/events.go +++ b/cmd/api/handlers/events.go @@ -111,6 +111,9 @@ func (h *HandlersApi) EventsHandler(w http.ResponseWriter, r *http.Request) { ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) username := ctx[ctxUser] authorized := func() bool { + // Refresh the snapshot before every batch and heartbeat so revocations + // from any process take effect before sending another event. + r := r.WithContext(users.WithPermissionCache(r.Context())) if !h.EventsAuthenticate(r) { return false } @@ -123,7 +126,7 @@ func (h *HandlersApi) EventsHandler(w http.ResponseWriter, r *http.Request) { } for _, topic := range topics { if topic == events.Alerts || topic == events.ServiceCommands || topic == events.Fleet { - if !h.Users.CheckPermissions(username, users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), username, users.AdminLevel, users.NoEnvironment) { return false } continue @@ -135,7 +138,7 @@ func (h *HandlersApi) EventsHandler(w http.ResponseWriter, r *http.Request) { if topic == events.Console || topic == events.FileExplorer { level = users.AdminLevel } - if !h.Users.CheckPermissions(username, level, environmentUUID) { + if !h.Users.CheckPermissionsContext(r.Context(), username, level, environmentUUID) { return false } } diff --git a/cmd/api/handlers/get.go b/cmd/api/handlers/get.go index 12ba0956..af3214be 100644 --- a/cmd/api/handlers/get.go +++ b/cmd/api/handlers/get.go @@ -89,7 +89,7 @@ func (h *HandlersApi) CheckHandlerAuth(w http.ResponseWriter, r *http.Request) { } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.UserLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.UserLevel, users.NoEnvironment) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } diff --git a/cmd/api/handlers/health_status.go b/cmd/api/handlers/health_status.go index 48b8d0b8..718be2d6 100644 --- a/cmd/api/handlers/health_status.go +++ b/cmd/api/handlers/health_status.go @@ -4,10 +4,8 @@ import ( "context" "fmt" "net/http" - "strings" "time" - "github.com/jmpsec/osctrl/pkg/auditlog" "github.com/jmpsec/osctrl/pkg/config" "github.com/jmpsec/osctrl/pkg/events" "github.com/jmpsec/osctrl/pkg/health" @@ -51,7 +49,7 @@ func (h *HandlersApi) HealthStatusHandler(w http.ResponseWriter, r *http.Request utils.DebugHTTPDump(h.DebugHTTP, r, false) } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use health API by user %s", ctx[ctxUser])) return } @@ -84,7 +82,6 @@ func (h *HandlersApi) HealthStatusHandler(w http.ResponseWriter, r *http.Request upgrade = h.HealthVersions.Info(row.Version) } - h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, healthStatusResponse{ GeneratedAt: now, Components: components, diff --git a/cmd/api/handlers/log_sinks.go b/cmd/api/handlers/log_sinks.go index 2cadf225..b53edac2 100644 --- a/cmd/api/handlers/log_sinks.go +++ b/cmd/api/handlers/log_sinks.go @@ -9,7 +9,6 @@ import ( "strings" "time" - "github.com/jmpsec/osctrl/pkg/auditlog" "github.com/jmpsec/osctrl/pkg/config" "github.com/jmpsec/osctrl/pkg/logsinks" "github.com/jmpsec/osctrl/pkg/servicecommands" @@ -69,7 +68,7 @@ func toLogSinkDTO(s logsinks.LogSink, reveal bool) logSinkDTO { func (h *HandlersApi) requireLogSinksAdmin(w http.ResponseWriter, r *http.Request) (string, bool) { ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use log-sinks API by user %s", ctx[ctxUser])) return "", false } @@ -111,7 +110,7 @@ func (h *HandlersApi) LogSinksListHandler(w http.ResponseWriter, r *http.Request if h.DebugHTTPConfig != nil && h.DebugHTTPConfig.EnableHTTP { utils.DebugHTTPDump(h.DebugHTTP, r, false) } - user, ok := h.requireLogSinksAdmin(w, r) + _, ok := h.requireLogSinksAdmin(w, r) if !ok { return } @@ -134,7 +133,6 @@ func (h *HandlersApi) LogSinksListHandler(w http.ResponseWriter, r *http.Request for _, row := range rows { out = append(out, toLogSinkDTO(row, reveal)) } - h.AuditLog.Visit(user, r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, out) } @@ -204,7 +202,7 @@ func (h *HandlersApi) LogSinksGetHandler(w http.ResponseWriter, r *http.Request) if h.DebugHTTPConfig != nil && h.DebugHTTPConfig.EnableHTTP { utils.DebugHTTPDump(h.DebugHTTP, r, false) } - user, ok := h.requireLogSinksAdmin(w, r) + _, ok := h.requireLogSinksAdmin(w, r) if !ok { return } @@ -226,7 +224,6 @@ func (h *HandlersApi) LogSinksGetHandler(w http.ResponseWriter, r *http.Request) apiErrorResponse(w, "error getting log sink", http.StatusInternalServerError, err) return } - h.AuditLog.Visit(user, r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, toLogSinkDTO(row, parseReveal(r))) } diff --git a/cmd/api/handlers/login.go b/cmd/api/handlers/login.go index 79a7f3ae..25aa3ebe 100644 --- a/cmd/api/handlers/login.go +++ b/cmd/api/handlers/login.go @@ -71,7 +71,7 @@ func (h *HandlersApi) LoginHandler(w http.ResponseWriter, r *http.Request) { apiErrorResponse(w, "environment not found", http.StatusNotFound, nil) return } - if !h.Users.CheckPermissions(l.Username, users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), l.Username, users.AdminLevel, env.UUID) { h.AuditLog.FailedLogin(l.Username, utils.GetIP(r), fmt.Sprintf("no admin access to env %s", env.UUID)) apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use %s by user %s", h.ServiceName, l.Username)) return diff --git a/cmd/api/handlers/logs.go b/cmd/api/handlers/logs.go index 4e7acc69..39ed8bbb 100644 --- a/cmd/api/handlers/logs.go +++ b/cmd/api/handlers/logs.go @@ -83,7 +83,7 @@ func (h *HandlersApi) NodeLogsHandler(w http.ResponseWriter, r *http.Request) { } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.UserLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.UserLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, nil) return } diff --git a/cmd/api/handlers/nodes.go b/cmd/api/handlers/nodes.go index d5d513bc..0c74c0ae 100644 --- a/cmd/api/handlers/nodes.go +++ b/cmd/api/handlers/nodes.go @@ -56,7 +56,7 @@ func (h *HandlersApi) NodeHandler(w http.ResponseWriter, r *http.Request) { } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -77,7 +77,6 @@ func (h *HandlersApi) NodeHandler(w http.ResponseWriter, r *http.Request) { return } log.Debug().Msgf("Returned node %s", nodeVar) - h.AuditLog.NodeAction(ctx[ctxUser], "viewed node "+nodeVar, strings.Split(r.RemoteAddr, ":")[0], env.ID) // Project to the SPA-facing view that surfaces parsed-and-sanitized // enrichment fields (CPU cores, BIOS, hardware vendor/model) parsed from // the otherwise-hidden RawEnrollment blob. The enroll_secret inside that @@ -123,7 +122,7 @@ func (h *HandlersApi) ActiveNodesHandler(w http.ResponseWriter, r *http.Request) } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -140,7 +139,6 @@ func (h *HandlersApi) ActiveNodesHandler(w http.ResponseWriter, r *http.Request) } // Serialize and serve JSON log.Debug().Msg("Returned active nodes") - h.AuditLog.NodeAction(ctx[ctxUser], "viewed active nodes", strings.Split(r.RemoteAddr, ":")[0], env.ID) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, nodeList) } @@ -180,7 +178,7 @@ func (h *HandlersApi) InactiveNodesHandler(w http.ResponseWriter, r *http.Reques } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -197,7 +195,6 @@ func (h *HandlersApi) InactiveNodesHandler(w http.ResponseWriter, r *http.Reques } // Serialize and serve JSON log.Debug().Msg("Returned inactive nodes") - h.AuditLog.NodeAction(ctx[ctxUser], "viewed inactive nodes", strings.Split(r.RemoteAddr, ":")[0], env.ID) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, nodeList) } @@ -237,7 +234,7 @@ func (h *HandlersApi) AllNodesHandler(w http.ResponseWriter, r *http.Request) { } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -253,7 +250,6 @@ func (h *HandlersApi) AllNodesHandler(w http.ResponseWriter, r *http.Request) { } // Serialize and serve JSON log.Debug().Msg("Returned all nodes") - h.AuditLog.NodeAction(ctx[ctxUser], "viewed all nodes", strings.Split(r.RemoteAddr, ":")[0], env.ID) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, nodeList) } @@ -295,7 +291,7 @@ func (h *HandlersApi) DeleteNodeHandler(w http.ResponseWriter, r *http.Request) } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -361,7 +357,7 @@ func (h *HandlersApi) TagNodeHandler(w http.ResponseWriter, r *http.Request) { } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -420,7 +416,7 @@ func (h *HandlersApi) LookupNodeHandler(w http.ResponseWriter, r *http.Request) } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -444,7 +440,6 @@ func (h *HandlersApi) LookupNodeHandler(w http.ResponseWriter, r *http.Request) return } log.Debug().Msgf("Looked up node %s", l.Identifier) - h.AuditLog.NodeAction(ctx[ctxUser], "looked up node "+l.Identifier, strings.Split(r.RemoteAddr, ":")[0], n.ID) // Serialize and serve JSON utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, n) } @@ -598,7 +593,7 @@ func (h *HandlersApi) NodesPagedHandler(w http.ResponseWriter, r *http.Request) // auth context — user ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.UserLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.UserLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -673,7 +668,6 @@ func (h *HandlersApi) NodesPagedHandler(w http.ResponseWriter, r *http.Request) } log.Debug().Msgf("Returned paged nodes for env %s page %d", env.Name, page) - h.AuditLog.NodeAction(ctx[ctxUser], "viewed paged nodes", strings.Split(r.RemoteAddr, ":")[0], env.ID) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.NodesPagedResponse{ // ProjectNodes adds the parsed `system_info` enrichment block per row. // The enroll_secret inside RawEnrollment is intentionally excluded. @@ -784,7 +778,7 @@ func (h *HandlersApi) NodePostureHandler(w http.ResponseWriter, r *http.Request) apiErrorResponse(w, "error getting environment", http.StatusNotFound, err) return } - if !h.Users.CheckPermissions(user, users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), user, users.AdminLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt by %s", user)) return } @@ -809,7 +803,6 @@ func (h *HandlersApi) NodePostureHandler(w http.ResponseWriter, r *http.Request) if records == nil { records = []posture.NodePosture{} } - h.AuditLog.NodeAction(ctx[ctxUser], "viewed posture for "+uuidVar, strings.Split(r.RemoteAddr, ":")[0], env.ID) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, records) } @@ -850,7 +843,7 @@ func (h *HandlersApi) NodePostureScoreHandler(w http.ResponseWriter, r *http.Req apiErrorResponse(w, "error getting environment", http.StatusNotFound, err) return } - if !h.Users.CheckPermissions(user, users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), user, users.AdminLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt by %s", user)) return } diff --git a/cmd/api/handlers/platforms.go b/cmd/api/handlers/platforms.go index a60b75d3..29b3b5b6 100644 --- a/cmd/api/handlers/platforms.go +++ b/cmd/api/handlers/platforms.go @@ -3,9 +3,7 @@ package handlers import ( "fmt" "net/http" - "strings" - "github.com/jmpsec/osctrl/pkg/auditlog" "github.com/jmpsec/osctrl/pkg/users" "github.com/jmpsec/osctrl/pkg/utils" "github.com/rs/zerolog/log" @@ -19,7 +17,7 @@ func (h *HandlersApi) PlatformsHandler(w http.ResponseWriter, r *http.Request) { } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -31,7 +29,6 @@ func (h *HandlersApi) PlatformsHandler(w http.ResponseWriter, r *http.Request) { } // Serialize and serve JSON log.Debug().Msg("Returned platforms") - h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, platforms) } @@ -75,7 +72,7 @@ func (h *HandlersApi) PlatformsEnvHandler(w http.ResponseWriter, r *http.Request } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -87,6 +84,5 @@ func (h *HandlersApi) PlatformsEnvHandler(w http.ResponseWriter, r *http.Request } // Serialize and serve JSON log.Debug().Msg("Returned platforms") - h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, platforms) } diff --git a/cmd/api/handlers/queries.go b/cmd/api/handlers/queries.go index fc54a8db..4f813394 100644 --- a/cmd/api/handlers/queries.go +++ b/cmd/api/handlers/queries.go @@ -80,7 +80,7 @@ func (h *HandlersApi) QueryShowHandler(w http.ResponseWriter, r *http.Request) { } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.QueryLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.QueryLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -120,7 +120,6 @@ func (h *HandlersApi) QueryShowHandler(w http.ResponseWriter, r *http.Request) { } // Serialize and serve JSON log.Debug().Msgf("Returned query %s", name) - h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, resp) } @@ -162,7 +161,7 @@ func (h *HandlersApi) QueriesRunHandler(w http.ResponseWriter, r *http.Request) } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.QueryLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.QueryLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -180,14 +179,14 @@ func (h *HandlersApi) QueriesRunHandler(w http.ResponseWriter, r *http.Request) } // Check if query is carve and user has permissions to carve if queries.IsCarveQuery(q.Query) { - if !h.Users.CheckPermissions(ctx[ctxUser], users.CarveLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.CarveLevel, env.UUID) { apiErrorResponse(w, fmt.Sprintf("%s has insufficient permissions to carve", ctx[ctxUser]), http.StatusForbidden, nil) return } } // Make sure the user has permissions to run queries in the environments for _, e := range q.Environments { - if !h.Users.CheckPermissions(ctx[ctxUser], users.QueryLevel, e) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.QueryLevel, e) { apiErrorResponse(w, fmt.Sprintf("%s has insufficient permissions to run queries in environment %s", ctx[ctxUser], e), http.StatusForbidden, nil) return } @@ -304,7 +303,7 @@ func (h *HandlersApi) QueriesActionHandler(w http.ResponseWriter, r *http.Reques } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -393,7 +392,7 @@ func (h *HandlersApi) AllQueriesShowHandler(w http.ResponseWriter, r *http.Reque } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.QueryLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.QueryLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -409,7 +408,6 @@ func (h *HandlersApi) AllQueriesShowHandler(w http.ResponseWriter, r *http.Reque } // Serialize and serve JSON log.Debug().Msgf("Returned %d queries", len(queries)) - h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, queries) } @@ -457,7 +455,7 @@ func (h *HandlersApi) QueryListHandler(w http.ResponseWriter, r *http.Request) { } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.QueryLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.QueryLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -518,7 +516,6 @@ func (h *HandlersApi) QueryListHandler(w http.ResponseWriter, r *http.Request) { // Serialize and serve JSON log.Debug().Msgf("Returned %d queries (page %d of %d)", len(items), page, totalPages) - h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, resp) } @@ -573,7 +570,7 @@ func (h *HandlersApi) QueryResultsHandler(w http.ResponseWriter, r *http.Request } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.QueryLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.QueryLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -629,7 +626,6 @@ func (h *HandlersApi) QueryResultsHandler(w http.ResponseWriter, r *http.Request Since: sinceEcho, } log.Debug().Msgf("Returned query results for %s (page %d of %d, %d rows)", name, page, totalPages, len(items)) - h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, resp) } @@ -681,7 +677,7 @@ func (h *HandlersApi) QueryResultsCSVHandler(w http.ResponseWriter, r *http.Requ } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.QueryLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.QueryLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } diff --git a/cmd/api/handlers/saved_queries.go b/cmd/api/handlers/saved_queries.go index d0f22159..6dcc306a 100644 --- a/cmd/api/handlers/saved_queries.go +++ b/cmd/api/handlers/saved_queries.go @@ -70,7 +70,7 @@ func (h *HandlersApi) SavedQueriesListHandler(w http.ResponseWriter, r *http.Req return } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.QueryLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.QueryLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -112,7 +112,6 @@ func (h *HandlersApi) SavedQueriesListHandler(w http.ResponseWriter, r *http.Req TotalPages: totalPages, } log.Debug().Msgf("Returned %d saved queries (page %d of %d)", len(items), page, totalPages) - h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, resp) } @@ -153,7 +152,7 @@ func (h *HandlersApi) SavedQueryCreateHandler(w http.ResponseWriter, r *http.Req return } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.QueryLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.QueryLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -243,7 +242,7 @@ func (h *HandlersApi) SavedQueryUpdateHandler(w http.ResponseWriter, r *http.Req return } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.QueryLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.QueryLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -311,7 +310,7 @@ func (h *HandlersApi) SavedQueryDeleteHandler(w http.ResponseWriter, r *http.Req return } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.QueryLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.QueryLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } diff --git a/cmd/api/handlers/service_config.go b/cmd/api/handlers/service_config.go index b00ebca1..2a8bfae3 100644 --- a/cmd/api/handlers/service_config.go +++ b/cmd/api/handlers/service_config.go @@ -10,7 +10,6 @@ import ( "strings" "time" - "github.com/jmpsec/osctrl/pkg/auditlog" "github.com/jmpsec/osctrl/pkg/config" "github.com/jmpsec/osctrl/pkg/servicecommands" "github.com/jmpsec/osctrl/pkg/serviceconfig" @@ -39,7 +38,7 @@ func (h *HandlersApi) ServiceConfigHandler(w http.ResponseWriter, r *http.Reques utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -53,7 +52,6 @@ func (h *HandlersApi) ServiceConfigHandler(w http.ResponseWriter, r *http.Reques return } log.Debug().Msg("Returned all service config sections") - h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, sections) } @@ -75,7 +73,7 @@ func (h *HandlersApi) ServiceConfigServiceHandler(w http.ResponseWriter, r *http utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -98,7 +96,6 @@ func (h *HandlersApi) ServiceConfigServiceHandler(w http.ResponseWriter, r *http return } log.Debug().Msgf("Returned service config for %s", service) - h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, sections) } @@ -122,7 +119,7 @@ func (h *HandlersApi) ServiceConfigSectionHandler(w http.ResponseWriter, r *http utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -158,7 +155,6 @@ func (h *HandlersApi) ServiceConfigSectionHandler(w http.ResponseWriter, r *http return } log.Debug().Msgf("Returned service config %s/%s", service, section) - h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, sc) } @@ -198,7 +194,7 @@ func (h *HandlersApi) ServiceConfigUpdateHandler(w http.ResponseWriter, r *http. utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -313,7 +309,7 @@ func (h *HandlersApi) ServiceConfigApplyHandler(w http.ResponseWriter, r *http.R utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -392,7 +388,7 @@ func (h *HandlersApi) ServiceConfigStatusHandler(w http.ResponseWriter, r *http. utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -430,7 +426,6 @@ func (h *HandlersApi) ServiceConfigStatusHandler(w http.ResponseWriter, r *http. resp.FileReason = status.Reason resp.CheckedAt = status.CheckedAt } - h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, resp) } @@ -467,7 +462,7 @@ func (h *HandlersApi) ServiceConfigPersistHandler(w http.ResponseWriter, r *http utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -555,7 +550,7 @@ func (h *HandlersApi) ServiceCommandHandler(w http.ResponseWriter, r *http.Reque utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } diff --git a/cmd/api/handlers/settings.go b/cmd/api/handlers/settings.go index 339ca524..857b076f 100644 --- a/cmd/api/handlers/settings.go +++ b/cmd/api/handlers/settings.go @@ -3,9 +3,7 @@ package handlers import ( "fmt" "net/http" - "strings" - "github.com/jmpsec/osctrl/pkg/auditlog" "github.com/jmpsec/osctrl/pkg/settings" "github.com/jmpsec/osctrl/pkg/users" "github.com/jmpsec/osctrl/pkg/utils" @@ -35,7 +33,7 @@ func (h *HandlersApi) SettingsHandler(w http.ResponseWriter, r *http.Request) { } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -47,7 +45,6 @@ func (h *HandlersApi) SettingsHandler(w http.ResponseWriter, r *http.Request) { } // Serialize and serve JSON log.Debug().Msg("Returned settings") - h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, serviceSettings) } @@ -86,7 +83,7 @@ func (h *HandlersApi) SettingsServiceHandler(w http.ResponseWriter, r *http.Requ } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -98,7 +95,6 @@ func (h *HandlersApi) SettingsServiceHandler(w http.ResponseWriter, r *http.Requ } // Serialize and serve JSON log.Debug().Msg("Returned settings") - h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, serviceSettings) } @@ -154,7 +150,7 @@ func (h *HandlersApi) SettingsServiceEnvHandler(w http.ResponseWriter, r *http.R } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -169,6 +165,5 @@ func (h *HandlersApi) SettingsServiceEnvHandler(w http.ResponseWriter, r *http.R } // Serialize and serve JSON log.Debug().Msg("Returned settings") - h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, serviceSettings) } diff --git a/cmd/api/handlers/settings_patch.go b/cmd/api/handlers/settings_patch.go index ba82c058..9d10cd1a 100644 --- a/cmd/api/handlers/settings_patch.go +++ b/cmd/api/handlers/settings_patch.go @@ -52,7 +52,7 @@ func (h *HandlersApi) SettingPatchHandler(w http.ResponseWriter, r *http.Request utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -108,6 +108,12 @@ func (h *HandlersApi) SettingPatchHandler(w http.ResponseWriter, r *http.Request return } } + if service == config.ServiceAPI && name == settings.AuditLogRetentionDays { + if err := settings.ValidateAuditLogRetentionDays(*body.Integer); err != nil { + apiErrorResponse(w, err.Error(), http.StatusBadRequest, err) + return + } + } if err := h.Settings.SetInteger(*body.Integer, service, name, settings.NoEnvironmentID); err != nil { apiErrorResponse(w, "error updating setting", http.StatusInternalServerError, err) return diff --git a/cmd/api/handlers/stats.go b/cmd/api/handlers/stats.go index 42a3dd83..eeb326e7 100644 --- a/cmd/api/handlers/stats.go +++ b/cmd/api/handlers/stats.go @@ -116,7 +116,7 @@ func (h *HandlersApi) StatsHandler(w http.ResponseWriter, r *http.Request) { for _, e := range allEnvs { // Filter to envs the user can actually see. - if !h.Users.CheckPermissions(user, users.UserLevel, e.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), user, users.UserLevel, e.UUID) { continue } if countErr != nil { @@ -274,7 +274,7 @@ func (h *HandlersApi) EnvActivityHandler(w http.ResponseWriter, r *http.Request) apiErrorResponse(w, "error getting environment", http.StatusNotFound, err) return } - if !h.Users.CheckPermissions(user, users.UserLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), user, users.UserLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", user)) return } @@ -323,7 +323,7 @@ func (h *HandlersApi) EnvActivityHandler(w http.ResponseWriter, r *http.Request) out[idx].Query += int(row.Cnt) case auditlog.LogTypeCarve: out[idx].Carve += int(row.Cnt) - case auditlog.LogTypeNode: + case auditlog.LogTypeEnroll: out[idx].Enroll += int(row.Cnt) } } @@ -400,7 +400,7 @@ func (h *HandlersApi) NodeActivityHandler(w http.ResponseWriter, r *http.Request apiErrorResponse(w, "error getting environment", http.StatusNotFound, err) return } - if !h.Users.CheckPermissions(user, users.UserLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), user, users.UserLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", user)) return } @@ -602,7 +602,7 @@ func (h *HandlersApi) NodeActivityBatchHandler(w http.ResponseWriter, r *http.Re apiErrorResponse(w, "error getting environment", http.StatusNotFound, err) return } - if !h.Users.CheckPermissions(user, users.UserLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), user, users.UserLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", user)) return } @@ -802,7 +802,7 @@ func (h *HandlersApi) NodeActivityTilesHandler(w http.ResponseWriter, r *http.Re apiErrorResponse(w, "error getting environment", http.StatusNotFound, err) return } - if !h.Users.CheckPermissions(user, users.UserLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), user, users.UserLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", user)) return } @@ -881,7 +881,7 @@ func (h *HandlersApi) NodeActivityTilesBatchHandler(w http.ResponseWriter, r *ht apiErrorResponse(w, "error getting environment", http.StatusNotFound, err) return } - if !h.Users.CheckPermissions(user, users.UserLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), user, users.UserLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", user)) return } @@ -979,7 +979,7 @@ func (h *HandlersApi) EnvActivityTilesHandler(w http.ResponseWriter, r *http.Req apiErrorResponse(w, "error getting environment", http.StatusNotFound, err) return } - if !h.Users.CheckPermissions(user, users.UserLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), user, users.UserLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", user)) return } @@ -1046,7 +1046,7 @@ func (h *HandlersApi) EnvErrorNodesHandler(w http.ResponseWriter, r *http.Reques apiErrorResponse(w, "error getting environment", http.StatusNotFound, err) return } - if !h.Users.CheckPermissions(user, users.UserLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), user, users.UserLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", user)) return } diff --git a/cmd/api/handlers/tags.go b/cmd/api/handlers/tags.go index 357282c1..c9740be4 100644 --- a/cmd/api/handlers/tags.go +++ b/cmd/api/handlers/tags.go @@ -6,7 +6,6 @@ import ( "net/http" "strings" - "github.com/jmpsec/osctrl/pkg/auditlog" "github.com/jmpsec/osctrl/pkg/tags" "github.com/jmpsec/osctrl/pkg/types" "github.com/jmpsec/osctrl/pkg/users" @@ -37,7 +36,7 @@ func (h *HandlersApi) AllTagsHandler(w http.ResponseWriter, r *http.Request) { } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -49,7 +48,6 @@ func (h *HandlersApi) AllTagsHandler(w http.ResponseWriter, r *http.Request) { } // Serialize and serve JSON log.Debug().Msgf("Returned %d tags", len(tags)) - h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, tags) } @@ -98,7 +96,7 @@ func (h *HandlersApi) TagEnvHandler(w http.ResponseWriter, r *http.Request) { return } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -108,7 +106,6 @@ func (h *HandlersApi) TagEnvHandler(w http.ResponseWriter, r *http.Request) { return } log.Debug().Msg("Returned tag") - h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, tag) } @@ -149,7 +146,7 @@ func (h *HandlersApi) TagsEnvHandler(w http.ResponseWriter, r *http.Request) { return } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -163,7 +160,6 @@ func (h *HandlersApi) TagsEnvHandler(w http.ResponseWriter, r *http.Request) { tagList = []tags.AdminTag{} } log.Debug().Msgf("Returned %d tags", len(tagList)) - h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, tagList) } @@ -209,7 +205,7 @@ func (h *HandlersApi) TagsActionHandler(w http.ResponseWriter, r *http.Request) return } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } diff --git a/cmd/api/handlers/users.go b/cmd/api/handlers/users.go index c3bc01ea..ead84619 100644 --- a/cmd/api/handlers/users.go +++ b/cmd/api/handlers/users.go @@ -6,7 +6,6 @@ import ( "net/http" "strings" - "github.com/jmpsec/osctrl/pkg/auditlog" "github.com/jmpsec/osctrl/pkg/types" "github.com/jmpsec/osctrl/pkg/users" "github.com/jmpsec/osctrl/pkg/utils" @@ -58,7 +57,7 @@ func (h *HandlersApi) UserHandler(w http.ResponseWriter, r *http.Request) { } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -78,7 +77,6 @@ func (h *HandlersApi) UserHandler(w http.ResponseWriter, r *http.Request) { // is only available to the user themselves via /api/v1/users/me. // log.Debug().Msgf("Returned user %s", usernameVar) - h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, projectAdminUserView(user)) } @@ -105,7 +103,7 @@ func (h *HandlersApi) UsersHandler(w http.ResponseWriter, r *http.Request) { } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -126,7 +124,6 @@ func (h *HandlersApi) UsersHandler(w http.ResponseWriter, r *http.Request) { views = append(views, projectAdminUserView(u)) } log.Debug().Msgf("Returned %d users", len(views)) - h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, views) } @@ -157,7 +154,7 @@ func (h *HandlersApi) UserActionHandler(w http.ResponseWriter, r *http.Request) } // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } diff --git a/cmd/api/handlers/users_profile.go b/cmd/api/handlers/users_profile.go index 7595d1c5..5c5adb08 100644 --- a/cmd/api/handlers/users_profile.go +++ b/cmd/api/handlers/users_profile.go @@ -52,7 +52,7 @@ func (h *HandlersApi) GetUserPermissionsHandler(w http.ResponseWriter, r *http.R utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -114,7 +114,7 @@ func (h *HandlersApi) SetUserPermissionsHandler(w http.ResponseWriter, r *http.R utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -231,7 +231,7 @@ func (h *HandlersApi) SetUserPermissionsAllHandler(w http.ResponseWriter, r *htt utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } @@ -348,7 +348,7 @@ func (h *HandlersApi) RefreshUserTokenHandler(w http.ResponseWriter, r *http.Req } requester := ctx[ctxUser] isSelf := username == requester - if !isSelf && !h.Users.CheckPermissions(requester, users.AdminLevel, users.NoEnvironment) { + if !isSelf && !h.Users.CheckPermissionsContext(r.Context(), requester, users.AdminLevel, users.NoEnvironment) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to refresh token for %s by %s", username, requester)) return } @@ -441,7 +441,7 @@ func (h *HandlersApi) DeleteUserTokenHandler(w http.ResponseWriter, r *http.Requ } requester := ctx[ctxUser] isSelf := username == requester - if !isSelf && !h.Users.CheckPermissions(requester, users.AdminLevel, users.NoEnvironment) { + if !isSelf && !h.Users.CheckPermissionsContext(r.Context(), requester, users.AdminLevel, users.NoEnvironment) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to delete token for %s by %s", username, requester)) return } diff --git a/cmd/api/main.go b/cmd/api/main.go index 92b4dca2..cfa84042 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -553,6 +553,9 @@ func osctrlAPIService() { if err != nil { log.Fatal().Msgf("Error initializing audit log manager - %v", err) } + auditRetentionCtx, stopAuditRetention := context.WithCancel(context.Background()) + defer stopAuditRetention() + go auditLog.RunRetention(auditRetentionCtx, settingsmgr.AuditLogRetentionDays) // Secondary indexes for the hot read paths, beyond the struct tags. Built // in the background, so a large table does not hold up startup; osctrl-api // and osctrl-tls both run this and are safe to race. @@ -1393,6 +1396,7 @@ func osctrlAPIService() { log.Fatal().Msgf("ListenAndServe: %v", err) } case <-shutdownSignals: + stopAuditRetention() if eventBus != nil { eventBus.Close() } @@ -1402,6 +1406,7 @@ func osctrlAPIService() { } cancelDrain() case <-restartCh: + stopAuditRetention() if eventBus != nil { eventBus.Close() } diff --git a/cmd/api/settings.go b/cmd/api/settings.go index e135aa19..4dace567 100644 --- a/cmd/api/settings.go +++ b/cmd/api/settings.go @@ -31,5 +31,10 @@ func loadingSettings(mgr *settings.Settings, cfg *config.ServiceParameters) erro return fmt.Errorf("failed to add %s to configuration: %w", settings.InactiveHours, err) } } + if !mgr.IsValue(config.ServiceAPI, settings.AuditLogRetentionDays, settings.NoEnvironmentID) { + if err := mgr.NewIntegerValue(config.ServiceAPI, settings.AuditLogRetentionDays, settings.DefaultAuditLogRetentionDays, settings.NoEnvironmentID); err != nil { + return fmt.Errorf("failed to add audit retention setting: %w", err) + } + } return nil } diff --git a/cmd/tls/handlers/enroll_audit_test.go b/cmd/tls/handlers/enroll_audit_test.go new file mode 100644 index 00000000..ea409c86 --- /dev/null +++ b/cmd/tls/handlers/enroll_audit_test.go @@ -0,0 +1,61 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/jmpsec/osctrl/pkg/auditlog" + "github.com/jmpsec/osctrl/pkg/config" + "github.com/jmpsec/osctrl/pkg/environments" + "github.com/jmpsec/osctrl/pkg/nodes" + "github.com/jmpsec/osctrl/pkg/tags" + "github.com/jmpsec/osctrl/pkg/types" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestEnrollAuditsOnlyPersistedSuccess(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + envs := environments.CreateEnvironment(db) + env := environments.TLSEnvironment{UUID: "11111111-1111-4111-8111-111111111111", Name: "env", Secret: "enrollment-test-secret", AcceptEnrolls: true} + require.NoError(t, db.Create(&env).Error) + audit, err := auditlog.CreateAuditLogManager(db, config.ServiceTLS, true) + require.NoError(t, err) + h := CreateHandlersTLS(WithEnvs(envs), WithNodes(nodes.CreateNodes(db)), WithTags(tags.CreateTagManager(db))) + h.AuditLog = audit + enroll := func(secret string) *httptest.ResponseRecorder { + body, err := json.Marshal(types.EnrollRequest{EnrollSecret: secret, HostIdentifier: "22222222-2222-4222-8222-222222222222"}) + require.NoError(t, err) + r := httptest.NewRequest(http.MethodPost, "/enroll", bytes.NewReader(body)) + r.SetPathValue("env", env.UUID) + w := httptest.NewRecorder() + h.EnrollHandler(w, r) + return w + } + countEnrolls := func() int64 { + var count int64 + require.NoError(t, db.Model(&auditlog.AuditLog{}).Where("log_type = ?", auditlog.LogTypeEnroll).Count(&count).Error) + return count + } + require.Equal(t, http.StatusForbidden, enroll("wrong").Code) + require.Zero(t, countEnrolls()) + for i := 1; i <= 2; i++ { + w := enroll(env.Secret) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var result types.EnrollResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result)) + require.False(t, result.NodeInvalid) + require.EqualValues(t, i, countEnrolls(), "re-enrollment is also a successful enrollment") + } + require.NoError(t, db.Migrator().DropTable(&nodes.OsqueryNode{})) + w := enroll(env.Secret) + var result types.EnrollResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result)) + require.True(t, result.NodeInvalid) + require.EqualValues(t, 2, countEnrolls(), "a failed database write must not count as enrollment") +} diff --git a/cmd/tls/handlers/post.go b/cmd/tls/handlers/post.go index 8bdedf62..021924db 100644 --- a/cmd/tls/handlers/post.go +++ b/cmd/tls/handlers/post.go @@ -166,6 +166,7 @@ func (h *HandlersTLS) EnrollHandler(w http.ResponseWriter, r *http.Request) { } if !nodeInvalid { h.recordActivity(env.UUID, newNode.UUID, activity.EventEnroll) + h.AuditLog.NewEnroll(newNode.UUID, utils.GetIP(r), env.ID) } response := types.EnrollResponse{NodeKey: nodeKey, NodeInvalid: nodeInvalid} // Debug HTTP diff --git a/frontend/src/api/audit.ts b/frontend/src/api/audit.ts index 851762c0..eadd1fd0 100644 --- a/frontend/src/api/audit.ts +++ b/frontend/src/api/audit.ts @@ -62,6 +62,7 @@ export const LOG_TYPE = { Visit: 9, User: 10, MCP: 11, + Enrollment: 12, } as const; export const LOG_TYPE_LABELS: Record = { @@ -76,4 +77,5 @@ export const LOG_TYPE_LABELS: Record = { 9: 'visit', 10: 'user', 11: 'mcp', + 12: 'enrollment', }; diff --git a/frontend/src/api/stats.ts b/frontend/src/api/stats.ts index b70b20f5..eb7dc062 100644 --- a/frontend/src/api/stats.ts +++ b/frontend/src/api/stats.ts @@ -67,7 +67,7 @@ export function getOsqueryVersionCounts(): Promise { * - config ← Setting (8) + Environment (7) * - query ← Query (4) * - carve ← Carve (5) - * - enroll ← Node (3) + * - enroll ← successful Enrollment (12) * * Buckets are returned contiguously — empty windows ship zero rows for that * bucket — so the SPA grid renders without densifying client-side. diff --git a/pkg/auditlog/audit.go b/pkg/auditlog/audit.go index c6898f33..0961eb90 100644 --- a/pkg/auditlog/audit.go +++ b/pkg/auditlog/audit.go @@ -25,6 +25,7 @@ var LogTypes = map[uint]struct{}{ LogTypeVisit: {}, LogTypeUser: {}, LogTypeMCP: {}, + LogTypeEnroll: {}, } // PageFilter describes the inputs accepted by GetPaged. @@ -128,6 +129,8 @@ const ( // agent activity out of the (much larger) volume of SPA/CLI-driven // entries, which carry no origin marker at all. LogTypeMCP = 11 + // LogTypeEnroll records successful agent enrollment only. + LogTypeEnroll = 12 // Severities SeverityInfo = 1 SeverityWarning = 2 @@ -303,7 +306,7 @@ func (m *AuditLogManager) QueryAction(username, action, ip string, envID uint) { return } line := fmt.Sprintf("user %s performed query action: %s", username, action) - if err := m.CreateNew(username, line, ip, LogTypeNode, SeverityInfo, envID); err != nil { + if err := m.CreateNew(username, line, ip, LogTypeQuery, SeverityInfo, envID); err != nil { log.Err(err).Msg("error creating query action audit log") } } @@ -559,3 +562,13 @@ func Indexes() []dbutil.Index { {Model: &AuditLog{}, Name: "idx_audit_logs_created_at", Columns: []string{"created_at"}}, } } + +// NewEnroll records a successful enrollment or re-enrollment after persistence. +func (m *AuditLogManager) NewEnroll(uuid, ip string, envID uint) { + if m == nil || !m.Enabled { + return + } + if err := m.CreateNew("osctrl-tls", "node enrolled: "+uuid, ip, LogTypeEnroll, SeverityInfo, envID); err != nil { + log.Err(err).Msg("error creating enrollment audit log") + } +} diff --git a/pkg/auditlog/retention.go b/pkg/auditlog/retention.go new file mode 100644 index 00000000..cbd77efd --- /dev/null +++ b/pkg/auditlog/retention.go @@ -0,0 +1,78 @@ +package auditlog + +import ( + "context" + "time" + + "github.com/jmpsec/osctrl/pkg/settings" + "github.com/rs/zerolog/log" +) + +const retentionBatchSize = 1000 + +// PruneBatch physically deletes at most 1000 expired rows, including rows +// already soft-deleted. Each statement is bounded and safe to retry or race +// with another API replica. Callers bound total work with a context deadline. +func (m *AuditLogManager) PruneBatch(ctx context.Context, days int64, now time.Time) (int64, error) { + if err := settings.ValidateAuditLogRetentionDays(days); err != nil { + return 0, err + } + cutoff := now.UTC().AddDate(0, 0, -int(days)) + db := m.DB.WithContext(ctx).Unscoped() + var ids []uint + if err := db.Model(&AuditLog{}).Where("created_at < ?", cutoff). + Order("created_at ASC").Limit(retentionBatchSize).Pluck("id", &ids).Error; err != nil { + return 0, err + } + if len(ids) == 0 { + return 0, nil + } + result := db.Where("id IN ? AND created_at < ?", ids, cutoff).Delete(&AuditLog{}) + return result.RowsAffected, result.Error +} + +// RunRetention runs on the API, pruning audit rows for all services even when +// new audit writes are disabled. The hourly sweep catches up gradually after +// downtime; its five-minute deadline prevents an unbounded cleanup job. +func (m *AuditLogManager) RunRetention(ctx context.Context, retention func() (int64, error)) { + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + for { + if ctx.Err() != nil { + return + } + days, err := retention() + if err == nil { + var total int64 + sweep, cancel := context.WithTimeout(ctx, 5*time.Minute) + now := time.Now() + for sweep.Err() == nil { + var deleted int64 + deleted, err = m.PruneBatch(sweep, days, now) + total += deleted + if err != nil || deleted == 0 { + break + } + // Yield between batches to avoid monopolizing the database. + timer := time.NewTimer(100 * time.Millisecond) + select { + case <-sweep.Done(): + timer.Stop() + case <-timer.C: + } + } + cancel() + if total > 0 { + log.Info().Int64("deleted", total).Msg("pruned audit history") + } + } + if err != nil && ctx.Err() == nil { + log.Error().Err(err).Msg("audit retention sweep failed") + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} diff --git a/pkg/auditlog/retention_test.go b/pkg/auditlog/retention_test.go new file mode 100644 index 00000000..b6348dba --- /dev/null +++ b/pkg/auditlog/retention_test.go @@ -0,0 +1,67 @@ +package auditlog + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestAuditRetentionBoundsAndPhysicalDeletion(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + m, err := CreateAuditLogManager(db, "api", false) + require.NoError(t, err) + now := time.Now().UTC() + cutoff := now.AddDate(0, 0, -90) + rows := make([]AuditLog, retentionBatchSize+1) + for i := range rows { + rows[i] = AuditLog{Model: gorm.Model{CreatedAt: cutoff.Add(-time.Second)}, Service: "tls", LogType: LogTypeNode} + } + require.NoError(t, db.CreateInBatches(&rows, 100).Error) + require.NoError(t, db.Delete(&rows[0]).Error) + boundary := AuditLog{Model: gorm.Model{CreatedAt: cutoff}, LogType: LogTypeLogin} + require.NoError(t, db.Create(&boundary).Error) + for _, invalid := range []int64{0, -1, 36501} { + _, err := m.PruneBatch(context.Background(), invalid, now) + require.Error(t, err) + } + deleted, err := m.PruneBatch(context.Background(), 90, now) + require.NoError(t, err) + require.EqualValues(t, retentionBatchSize, deleted) + deleted, err = m.PruneBatch(context.Background(), 90, now) + require.NoError(t, err) + require.EqualValues(t, 1, deleted) + deleted, err = m.PruneBatch(context.Background(), 90, now) + require.NoError(t, err) + require.Zero(t, deleted) + var remaining []AuditLog + require.NoError(t, db.Unscoped().Find(&remaining).Error) + require.Len(t, remaining, 1) + require.Equal(t, boundary.ID, remaining[0].ID) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = m.PruneBatch(ctx, 90, now) + require.ErrorIs(t, err, context.Canceled) +} + +func TestAuditActionTypes(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + m, err := CreateAuditLogManager(db, "tls", true) + require.NoError(t, err) + m.NewEnroll("NODE", "127.0.0.1", 1) + m.FailedEnroll("127.0.0.1", "env", "invalid secret", 1) + m.QueryAction("alice", "complete query", "127.0.0.1", 1) + var rows []AuditLog + require.NoError(t, db.Order("id").Find(&rows).Error) + require.Len(t, rows, 3) + require.EqualValues(t, LogTypeEnroll, rows[0].LogType) + require.EqualValues(t, LogTypeNode, rows[1].LogType) + require.EqualValues(t, LogTypeQuery, rows[2].LogType) + _, supported := LogTypes[LogTypeEnroll] + require.True(t, supported) +} diff --git a/pkg/auditlog/utils.go b/pkg/auditlog/utils.go index 1ecca37a..dacb89e5 100644 --- a/pkg/auditlog/utils.go +++ b/pkg/auditlog/utils.go @@ -12,6 +12,7 @@ const ( LogTypeSettingStr = "Setting" LogTypeVisitStr = "Visit" LogTypeUserStr = "User" + LogTypeEnrollStr = "Enrollment" LogTypeMCPStr = "MCP" LogTypeUnknown = "Unknown" // Severity strings @@ -44,6 +45,8 @@ func (m *AuditLogManager) LogTypeToString(logType uint) string { return LogTypeVisitStr case 10: return LogTypeUserStr + case LogTypeEnroll: + return LogTypeEnrollStr case 11: return LogTypeMCPStr default: diff --git a/pkg/auditlog/utils_test.go b/pkg/auditlog/utils_test.go index 96ca9a8c..3f4e6d6f 100644 --- a/pkg/auditlog/utils_test.go +++ b/pkg/auditlog/utils_test.go @@ -18,6 +18,7 @@ func TestLogTypeToString(t *testing.T) { {9, "Visit"}, {10, "User"}, {11, "MCP"}, + {12, "Enrollment"}, {0, "Unknown"}, {99, "Unknown"}, } diff --git a/pkg/dbutil/indexes.go b/pkg/dbutil/indexes.go index 6f6109a7..ca21e75a 100644 --- a/pkg/dbutil/indexes.go +++ b/pkg/dbutil/indexes.go @@ -30,10 +30,13 @@ type Index struct { Name string // Columns are database column names, in index order. Columns []string + // Replaces lists obsolete GORM field indexes to drop only after this + // replacement exists and is usable. Names respect the naming strategy. + Replaces []string } -// EnsureIndexes creates whichever of indexes do not exist yet, and is a no-op -// for the rest. It is safe to run from several processes at once: osctrl-api +// EnsureIndexes creates missing indexes and removes their declared obsolete +// field indexes after the replacements are usable. It is safe to run from several processes at once: osctrl-api // and osctrl-tls both run it at startup. // // On Postgres the build is CONCURRENTLY, so writers are never blocked however @@ -75,29 +78,59 @@ func BuildIndexes(db *gorm.DB, indexes ...Index) { func ensureIndex(db *gorm.DB, idx Index) error { if !db.Migrator().HasIndex(idx.Model, idx.Name) { - return createIndex(db, idx) - } - if db.Name() != "postgres" { - return nil + if err := createIndex(db, idx); err != nil { + return err + } } - var valid, building bool - err := db.Raw(`SELECT i.indisvalid, - EXISTS (SELECT 1 FROM pg_stat_progress_create_index p WHERE p.index_relid = c.oid) - FROM pg_class c - JOIN pg_index i ON i.indexrelid = c.oid - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE c.relname = ? AND n.nspname = current_schema()`, idx.Name).Row().Scan(&valid, &building) - if err != nil { - return fmt.Errorf("check validity: %w", err) + if db.Name() == "postgres" { + var valid, building bool + err := db.Raw(`SELECT i.indisvalid, + EXISTS (SELECT 1 FROM pg_stat_progress_create_index p WHERE p.index_relid = c.oid) + FROM pg_class c + JOIN pg_index i ON i.indexrelid = c.oid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = ? AND n.nspname = current_schema()`, idx.Name).Row().Scan(&valid, &building) + if err != nil { + return fmt.Errorf("check validity: %w", err) + } + // Never drop the fallback while the replacement is being built. + if building { + return nil + } + if !valid { + log.Warn().Str("index", idx.Name).Msg("rebuilding invalid index left by an interrupted concurrent build") + if err := db.Exec("DROP INDEX CONCURRENTLY IF EXISTS " + quote(db, idx.Name)).Error; err != nil { + return err + } + if err := createIndex(db, idx); err != nil { + return err + } + // IF NOT EXISTS may have raced with another builder. Recheck + // usability before removing any fallback index. + return ensureIndex(db, idx) + } } - if valid || building { - return nil + stmt := &gorm.Statement{DB: db} + if err := stmt.Parse(idx.Model); err != nil { + return err } - log.Warn().Str("index", idx.Name).Msg("rebuilding invalid index left by an interrupted concurrent build") - if err := db.Exec("DROP INDEX CONCURRENTLY IF EXISTS " + quote(db, idx.Name)).Error; err != nil { - return fmt.Errorf("drop invalid index: %w", err) + for _, field := range idx.Replaces { + name := db.NamingStrategy.IndexName(stmt.Schema.Table, field) + if !db.Migrator().HasIndex(idx.Model, name) { + continue + } + var err error + if db.Name() == "postgres" { + err = db.Exec("DROP INDEX CONCURRENTLY IF EXISTS " + quote(db, name)).Error + } else { + err = db.Migrator().DropIndex(idx.Model, name) + } + // Another process may have dropped it between the check and DDL. + if err != nil && db.Migrator().HasIndex(idx.Model, name) { + return fmt.Errorf("drop replaced index %s: %w", name, err) + } } - return createIndex(db, idx) + return nil } func createIndex(db *gorm.DB, idx Index) error { diff --git a/pkg/dbutil/indexes_test.go b/pkg/dbutil/indexes_test.go index 33d30c9d..b892f4e6 100644 --- a/pkg/dbutil/indexes_test.go +++ b/pkg/dbutil/indexes_test.go @@ -85,6 +85,21 @@ func TestEnsureIndexesContinuesPastFailures(t *testing.T) { require.True(t, db.Migrator().HasIndex(&widget{}, "idx_widgets_name")) } +func TestReplacementIndexDropsOnlyAfterSuccessfulBuild(t *testing.T) { + db := openSQLite(t, &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "replacement_"}}) + require.NoError(t, db.AutoMigrate(&widget{})) + oldName := db.NamingStrategy.IndexName("replacement_widgets", "kind") + require.NoError(t, db.Exec("CREATE INDEX "+oldName+" ON replacement_widgets (kind)").Error) + idx := dbutil.Index{Model: &widget{}, Name: "idx_replacement", Columns: []string{"kind", "missing"}, Replaces: []string{"kind"}} + require.Error(t, dbutil.EnsureIndexes(db, idx)) + require.True(t, db.Migrator().HasIndex(&widget{}, oldName), "failed builds must keep the fallback") + idx.Columns = []string{"kind", "created_at"} + require.NoError(t, dbutil.EnsureIndexes(db, idx)) + require.True(t, db.Migrator().HasIndex(&widget{}, idx.Name)) + require.False(t, db.Migrator().HasIndex(&widget{}, oldName)) + require.NoError(t, dbutil.EnsureIndexes(db, idx), "restart is idempotent") +} + func osctrlIndexes() []dbutil.Index { return slices.Concat(nodes.Indexes(), queries.Indexes(), auditlog.Indexes(), carves.Indexes(), tags.Indexes(), logging.Indexes()) @@ -153,6 +168,55 @@ func TestOsctrlIndexesBuildExternalBackend(t *testing.T) { } } +// Remove the historical standalone indexes, then restart. Optional DSNs use the +// same isolated table-prefix convention as the other real-engine tests. +func TestLogIndexUpgradeExternalBackend(t *testing.T) { + for _, backend := range []string{"POSTGRES", "MYSQL"} { + t.Run(backend, func(t *testing.T) { + db, prefix := externalDB(t, backend) + models := []any{&logging.OsqueryStatusData{}, &logging.OsqueryResultData{}, &logging.OsqueryQueryData{}} + t.Cleanup(func() { require.NoError(t, db.Migrator().DropTable(models...)) }) + require.NoError(t, db.AutoMigrate(models...)) + for _, model := range models[:2] { + stmt := &gorm.Statement{DB: db} + require.NoError(t, stmt.Parse(model)) + oldName := db.NamingStrategy.IndexName(stmt.Schema.Table, "uuid") + require.NoError(t, db.Exec("CREATE INDEX "+stmt.Quote(oldName)+" ON "+stmt.Quote(stmt.Schema.Table)+" ("+stmt.Quote("uuid")+")").Error) + require.NoError(t, db.Table(stmt.Schema.Table).Create(map[string]any{"uuid": "NODE-A"}).Error) + } + indexes := logging.Indexes() + for i := range indexes { + indexes[i].Name = prefix + indexes[i].Name + } + require.NoError(t, dbutil.EnsureIndexes(db, indexes...)) + require.NoError(t, db.AutoMigrate(models...)) + for _, model := range models[:2] { + stmt := &gorm.Statement{DB: db} + require.NoError(t, stmt.Parse(model)) + require.False(t, db.Migrator().HasIndex(model, db.NamingStrategy.IndexName(stmt.Schema.Table, "uuid"))) + columns, err := db.Migrator().ColumnTypes(model) + require.NoError(t, err) + for _, column := range columns { + if column.Name() != "uuid" { + continue + } + if backend == "MYSQL" { + require.Equal(t, "varchar", column.DatabaseTypeName()) + size, ok := column.Length() + require.True(t, ok) + require.EqualValues(t, 191, size) + } else { + require.Equal(t, "text", column.DatabaseTypeName()) + } + } + var count int64 + require.NoError(t, db.Model(model).Where("uuid = ?", "NODE-A").Count(&count).Error) + require.EqualValues(t, 1, count) + } + }) + } +} + // MySQL can only index TEXT by prefix, and rejects a prefix on a short // varchar: one index over both kinds proves the prefix goes on the TEXT column // alone. diff --git a/pkg/logging/db.go b/pkg/logging/db.go index 6505f48f..af1f56d9 100644 --- a/pkg/logging/db.go +++ b/pkg/logging/db.go @@ -21,7 +21,7 @@ import ( // OsqueryResultData to log result data to database type OsqueryResultData struct { gorm.Model - UUID string `gorm:"index"` + UUID NodeLogUUID Environment string Name string Action string @@ -33,7 +33,7 @@ type OsqueryResultData struct { // OsqueryStatusData to log status data to database type OsqueryStatusData struct { gorm.Model - UUID string `gorm:"index"` + UUID NodeLogUUID Environment string Line string Message string @@ -158,7 +158,7 @@ func (logDB *LoggerDB) Status(data []byte, environment, uuid string, debug bool) for _, l := range logs { entries = append(entries, OsqueryStatusData{ Model: stamp(), - UUID: strings.ToUpper(uuid), + UUID: NodeLogUUID(strings.ToUpper(uuid)), Environment: environment, Line: strconv.Itoa(int(l.Line)), Message: l.Message, @@ -183,7 +183,7 @@ func (logDB *LoggerDB) Result(data []byte, environment, uuid string, debug bool) for _, l := range logs { entries = append(entries, OsqueryResultData{ Model: stamp(), - UUID: strings.ToUpper(uuid), + UUID: NodeLogUUID(strings.ToUpper(uuid)), Environment: environment, Name: l.Name, Action: l.Action, @@ -384,7 +384,7 @@ func GetNodeLogs(db *gorm.DB, logType, env, uuid string, since time.Time, limit result = append(result, map[string]any{ "id": r.ID, "created_at": r.CreatedAt, - "uuid": r.UUID, + "uuid": string(r.UUID), "environment": r.Environment, "line": r.Line, "message": r.Message, @@ -413,7 +413,7 @@ func GetNodeLogs(db *gorm.DB, logType, env, uuid string, since time.Time, limit result = append(result, map[string]any{ "id": r.ID, "created_at": r.CreatedAt, - "uuid": r.UUID, + "uuid": string(r.UUID), "environment": r.Environment, "name": r.Name, "action": r.Action, @@ -607,8 +607,8 @@ func (logDB *LoggerDB) CleanQueryLogs(entries int64) error { // (uuid, created_at) a node's log page reads every row it has ever sent. func Indexes() []dbutil.Index { return []dbutil.Index{ - {Model: &OsqueryStatusData{}, Name: "idx_osquery_status_data_uuid_created", Columns: []string{"uuid", "created_at"}}, - {Model: &OsqueryResultData{}, Name: "idx_osquery_result_data_uuid_created", Columns: []string{"uuid", "created_at"}}, + {Model: &OsqueryStatusData{}, Name: "idx_osquery_status_data_uuid_created", Columns: []string{"uuid", "created_at"}, Replaces: []string{"uuid"}}, + {Model: &OsqueryResultData{}, Name: "idx_osquery_result_data_uuid_created", Columns: []string{"uuid", "created_at"}, Replaces: []string{"uuid"}}, {Model: &OsqueryQueryData{}, Name: "idx_osquery_query_data_name_created", Columns: []string{"name", "created_at"}}, } } diff --git a/pkg/logging/uuid.go b/pkg/logging/uuid.go new file mode 100644 index 00000000..dbd1159b --- /dev/null +++ b/pkg/logging/uuid.go @@ -0,0 +1,21 @@ +package logging + +import ( + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +// NodeLogUUID preserves the column type originally inferred from gorm:"index" +// without retaining that standalone index. A size tag alone would narrow +// PostgreSQL's existing text column; an untagged string would expand MySQL's +// varchar(191) to longtext. Both cause unwanted table rewrites on startup. +type NodeLogUUID string + +func (NodeLogUUID) GormDataType() string { return "string" } + +func (NodeLogUUID) GormDBDataType(db *gorm.DB, _ *schema.Field) string { + if db.Name() == "mysql" { + return "varchar(191)" + } + return "text" +} diff --git a/pkg/logging/uuid_test.go b/pkg/logging/uuid_test.go new file mode 100644 index 00000000..a4701e37 --- /dev/null +++ b/pkg/logging/uuid_test.go @@ -0,0 +1,76 @@ +package logging + +import ( + "sync" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/jmpsec/osctrl/pkg/dbutil" + "github.com/stretchr/testify/require" + "gorm.io/driver/mysql" + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +type legacyLogUUID struct { + gorm.Model + UUID string `gorm:"index"` +} + +func TestNodeLogUUIDPreservesColumnTypes(t *testing.T) { + for _, dialect := range []string{"mysql", "postgres", "sqlite"} { + t.Run(dialect, func(t *testing.T) { + conn, _, err := sqlmock.New() + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + var driver gorm.Dialector + switch dialect { + case "mysql": + driver = mysql.New(mysql.Config{Conn: conn, SkipInitializeWithVersion: true}) + case "postgres": + driver = postgres.New(postgres.Config{Conn: conn}) + default: + driver = sqlite.Open(":memory:") + } + db, err := gorm.Open(driver, &gorm.Config{DisableAutomaticPing: true}) + require.NoError(t, err) + legacy, err := schema.Parse(&legacyLogUUID{}, &sync.Map{}, schema.NamingStrategy{}) + require.NoError(t, err) + before := db.Migrator().FullDataTypeOf(legacy.LookUpField("UUID")) + for _, model := range []any{&OsqueryStatusData{}, &OsqueryResultData{}} { + current, err := schema.Parse(model, &sync.Map{}, schema.NamingStrategy{}) + require.NoError(t, err) + require.Equal(t, before, db.Migrator().FullDataTypeOf(current.LookUpField("UUID")), "upgrade must not change the UUID SQL type") + } + }) + } +} + +func TestLogUUIDIndexUpgradeAndRestart(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + for _, model := range []any{&OsqueryStatusData{}, &OsqueryResultData{}} { + stmt := &gorm.Statement{DB: db} + require.NoError(t, stmt.Parse(model)) + table := stmt.Schema.Table + oldName := db.NamingStrategy.IndexName(table, "uuid") + require.NoError(t, db.Table(table).AutoMigrate(&legacyLogUUID{})) + // Legacy model's naming differs; create the historical production name. + require.NoError(t, db.Exec("CREATE INDEX IF NOT EXISTS "+oldName+" ON "+table+" (uuid)").Error) + require.NoError(t, db.AutoMigrate(model)) + require.True(t, db.Migrator().HasIndex(model, oldName)) + } + require.NoError(t, db.AutoMigrate(&OsqueryQueryData{})) + require.NoError(t, dbutil.EnsureIndexes(db, Indexes()...)) + for _, model := range []any{&OsqueryStatusData{}, &OsqueryResultData{}} { + stmt := &gorm.Statement{DB: db} + require.NoError(t, stmt.Parse(model)) + oldName := db.NamingStrategy.IndexName(stmt.Schema.Table, "uuid") + require.False(t, db.Migrator().HasIndex(model, oldName)) + require.NoError(t, db.AutoMigrate(model)) + require.False(t, db.Migrator().HasIndex(model, oldName), "restart must not recreate the redundant index") + } + require.True(t, db.Migrator().HasIndex(&OsqueryQueryData{}, "idx_osquery_query_data_uuid"), "query log UUID index is not covered by name/created_at") +} diff --git a/pkg/settings/audit.go b/pkg/settings/audit.go new file mode 100644 index 00000000..25f3234b --- /dev/null +++ b/pkg/settings/audit.go @@ -0,0 +1,35 @@ +package settings + +import ( + "errors" + "fmt" + + "github.com/jmpsec/osctrl/pkg/config" + "gorm.io/gorm" +) + +const AuditLogRetentionDays = "audit_log_retention_days" +const DefaultAuditLogRetentionDays int64 = 90 + +func ValidateAuditLogRetentionDays(days int64) error { + if days < 1 || days > 36500 { + return fmt.Errorf("audit_log_retention_days must be between 1 and 36500") + } + return nil +} + +// AuditLogRetentionDays is shared by all services' audit rows. Invalid settings +// and database failures stop pruning rather than applying a shorter fallback. +func (conf *Settings) AuditLogRetentionDays() (int64, error) { + value, err := conf.RetrieveValue(config.ServiceAPI, AuditLogRetentionDays, NoEnvironmentID) + if errors.Is(err, gorm.ErrRecordNotFound) { + return DefaultAuditLogRetentionDays, nil + } + if err != nil { + return 0, err + } + if value.Type != TypeInteger { + return 0, fmt.Errorf("audit retention must be an integer") + } + return value.Integer, ValidateAuditLogRetentionDays(value.Integer) +} diff --git a/pkg/settings/audit_test.go b/pkg/settings/audit_test.go new file mode 100644 index 00000000..b347dc5b --- /dev/null +++ b/pkg/settings/audit_test.go @@ -0,0 +1,29 @@ +package settings + +import ( + "testing" + + "github.com/jmpsec/osctrl/pkg/config" + "github.com/stretchr/testify/require" +) + +func TestAuditRetentionSetting(t *testing.T) { + db := setupSettingsTestDB(t) + m := &Settings{DB: db} + days, err := m.AuditLogRetentionDays() + require.NoError(t, err) + require.Equal(t, DefaultAuditLogRetentionDays, days) + require.Error(t, m.NewIntegerValue(config.ServiceAPI, AuditLogRetentionDays, 0, 0)) + require.NoError(t, m.NewIntegerValue(config.ServiceAPI, AuditLogRetentionDays, 180, 0)) + days, err = m.AuditLogRetentionDays() + require.NoError(t, err) + require.EqualValues(t, 180, days) + require.Error(t, m.SetInteger(-1, config.ServiceAPI, AuditLogRetentionDays, 0)) + // A malformed direct database edit must stop pruning, never shorten retention. + require.NoError(t, db.Model(&SettingValue{}).Where("name = ?", AuditLogRetentionDays).Update("integer", 0).Error) + _, err = m.AuditLogRetentionDays() + require.Error(t, err) + require.NoError(t, db.Migrator().DropTable(&SettingValue{})) + _, err = m.AuditLogRetentionDays() + require.Error(t, err) +} diff --git a/pkg/settings/settings.go b/pkg/settings/settings.go index ff10b10b..45fd2d41 100644 --- a/pkg/settings/settings.go +++ b/pkg/settings/settings.go @@ -160,6 +160,15 @@ func (conf *Settings) NewValue(service, name, typeValue string, value interface{ return err } } + if service == config.ServiceAPI && name == AuditLogRetentionDays { + days, ok := value.(int64) + if typeValue != TypeInteger || !ok { + return fmt.Errorf("audit retention must be an integer") + } + if err := ValidateAuditLogRetentionDays(days); err != nil { + return err + } + } // Empty new value entry := conf.EmptyValue(service, name, typeValue, envID) switch typeValue { @@ -252,6 +261,11 @@ func (conf *Settings) GetMap(service string, envID uint) (MapSettings, error) { // SetInteger sets a numeric settings value by service and name func (conf *Settings) SetInteger(intValue int64, service, name string, envID uint) error { + if service == config.ServiceAPI && name == AuditLogRetentionDays { + if err := ValidateAuditLogRetentionDays(intValue); err != nil { + return err + } + } if service == config.ServiceAPI && name == InactiveHours { if err := ValidateInactiveHours(intValue); err != nil { return err diff --git a/pkg/users/permission_context.go b/pkg/users/permission_context.go new file mode 100644 index 00000000..4441a67d --- /dev/null +++ b/pkg/users/permission_context.go @@ -0,0 +1,82 @@ +package users + +import ( + "context" + "sync" +) + +type permissionContextKey struct{} + +// permissionCache lives for one request (or one stream authorization pass). +// Never share it between requests: the database remains authoritative for +// revocation, including changes made by another API replica or the CLI. +type permissionCache struct { + mu sync.Mutex + users map[string]AdminUser + permissions map[string]map[string][]UserPermission +} + +// WithPermissionCache starts a fresh, empty authorization snapshot. +func WithPermissionCache(ctx context.Context) context.Context { + return context.WithValue(ctx, permissionContextKey{}, &permissionCache{ + users: make(map[string]AdminUser), + permissions: make(map[string]map[string][]UserPermission), + }) +} + +// CacheAuthenticatedUser reuses the row just checked by token authentication. +// Call only after validating the stored token, with a fresh request cache. +func CacheAuthenticatedUser(ctx context.Context, user AdminUser) { + if cache, ok := ctx.Value(permissionContextKey{}).(*permissionCache); ok { + cache.mu.Lock() + defer cache.mu.Unlock() + cache.users[user.Username] = user + } +} + +// CheckPermissionsContext caches user and environment permission rows only +// within the supplied request. Without a cache it uses the authoritative +// CheckPermissions path, as do callers outside HTTP handlers. +func (m *UserManager) CheckPermissionsContext(ctx context.Context, username string, level AccessLevel, environment string) bool { + cache, ok := ctx.Value(permissionContextKey{}).(*permissionCache) + if !ok { + return m.CheckPermissions(username, level, environment) + } + cache.mu.Lock() + defer cache.mu.Unlock() + user, found := cache.users[username] + if !found { + if err := m.DB.WithContext(ctx).Where("username = ?", username).First(&user).Error; err != nil { + return false + } + cache.users[username] = user + } + if user.Admin || (environment == NoEnvironment && level == UserLevel) { + return true + } + byEnvironment := cache.permissions[username] + if byEnvironment == nil { + byEnvironment = make(map[string][]UserPermission) + cache.permissions[username] = byEnvironment + } + perms, found := byEnvironment[environment] + if !found { + if err := m.DB.WithContext(ctx).Where("username = ? AND environment = ?", username, environment).Find(&perms).Error; err != nil { + return false + } + byEnvironment[environment] = perms + } + return permissionsAllow(perms, level) +} + +func permissionsAllow(perms []UserPermission, level AccessLevel) bool { + for _, p := range perms { + if p.AccessType == int(AdminLevel) && p.AccessValue { + return true + } + if p.AccessType == int(level) { + return p.AccessValue + } + } + return false +} diff --git a/pkg/users/permission_context_test.go b/pkg/users/permission_context_test.go new file mode 100644 index 00000000..87b6e832 --- /dev/null +++ b/pkg/users/permission_context_test.go @@ -0,0 +1,54 @@ +package users + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestPermissionSnapshotQueriesAndRevocation(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + m := CreateUserManager(db) + user := AdminUser{Username: "alice"} + require.NoError(t, m.Create(user)) + for _, level := range []AccessLevel{QueryLevel, CarveLevel} { + require.NoError(t, m.CreatePermission(UserPermission{Username: user.Username, Environment: "one", AccessType: int(level), AccessValue: true})) + } + queries := 0 + require.NoError(t, db.Callback().Query().Before("gorm:query").Register("count_permissions", func(*gorm.DB) { queries++ })) + ctx := WithPermissionCache(context.Background()) + CacheAuthenticatedUser(ctx, user) + require.True(t, m.CheckPermissionsContext(ctx, "alice", QueryLevel, "one")) + require.True(t, m.CheckPermissionsContext(ctx, "alice", CarveLevel, "one")) + require.False(t, m.CheckPermissionsContext(ctx, "alice", AdminLevel, "one")) + require.Equal(t, 1, queries, "one permission query for every topic in the same environment") + require.False(t, m.CheckPermissionsContext(ctx, "alice", QueryLevel, "two")) + require.False(t, m.CheckPermissionsContext(ctx, "alice", QueryLevel, "two")) + require.Equal(t, 2, queries, "denials are cached and environments stay isolated") + require.False(t, m.CheckPermissionsContext(ctx, "missing", QueryLevel, "one")) + // Simulate a CLI or another replica changing the database directly. + require.NoError(t, db.Model(&UserPermission{}).Where("username = ?", "alice").Update("access_value", false).Error) + fresh := WithPermissionCache(ctx) + require.False(t, m.CheckPermissionsContext(fresh, "alice", QueryLevel, "one")) + require.False(t, m.CheckPermissionsContext(fresh, "alice", CarveLevel, "one")) + // Without middleware, checks remain authoritative too. + require.False(t, m.CheckPermissionsContext(context.Background(), "alice", QueryLevel, "one")) +} + +func TestPermissionSnapshotAdminDemotionAndDeletion(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + m := CreateUserManager(db) + user := AdminUser{Username: "admin", Admin: true} + require.NoError(t, m.Create(user)) + ctx := WithPermissionCache(context.Background()) + require.True(t, m.CheckPermissionsContext(ctx, "admin", AdminLevel, NoEnvironment)) + require.NoError(t, db.Model(&AdminUser{}).Where("username = ?", "admin").Update("admin", false).Error) + require.False(t, m.CheckPermissionsContext(WithPermissionCache(ctx), "admin", AdminLevel, NoEnvironment)) + require.NoError(t, db.Where("username = ?", "admin").Delete(&AdminUser{}).Error) + require.False(t, m.CheckPermissionsContext(WithPermissionCache(ctx), "admin", UserLevel, NoEnvironment)) +} diff --git a/pkg/users/permissions.go b/pkg/users/permissions.go index c0873dbf..4d6fd29c 100644 --- a/pkg/users/permissions.go +++ b/pkg/users/permissions.go @@ -141,16 +141,7 @@ func (m *UserManager) CheckPermissions(username string, level AccessLevel, envir if err := m.DB.Where("username = ? AND environment = ?", username, environment).Find(&perms).Error; err != nil { return false } - for _, p := range perms { - // Access is yes for admins - if p.AccessType == int(AdminLevel) && p.AccessValue { - return true - } - if p.AccessType == int(level) { - return p.AccessValue - } - } - return false + return permissionsAllow(perms, level) } // ChangePermissions for setting user permissions by username