")
@@ -95,7 +119,7 @@ func (s *Server) handleUsersPage(w http.ResponseWriter, r *http.Request) {
selfQuoted, _ := json.Marshal(u)
body := `
Users
-
Create portal accounts. Disabled users cannot sign in; their sessions end immediately. You cannot disable yourself. Admin may manage users and All logs; Operator may use beacons, commands, reports, topology, chat, and Engagement logs for the selected engagement.
+
Create portal accounts and change roles. Disabled users cannot sign in; their sessions end immediately. You cannot disable yourself or change your own role. Admin may manage users and All logs; Operator may use beacons, commands, reports, topology, chat, and Engagement logs for the selected engagement.
Create user
@@ -131,32 +155,48 @@ document.getElementById('createu').onclick = async function() {
out.textContent = r.ok ? JSON.stringify(j, null, 2) : (j.error || r.statusText);
};
document.getElementById('refusers').onclick = function() { location.reload(); };
-async function patchUser(username, disabled) {
+async function patchUser(username, body) {
var r = await fetch('/api/users/' + encodeURIComponent(username), {
method: 'PATCH',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ disabled: disabled })
+ body: JSON.stringify(body)
});
var j = await r.json().catch(function() { return {}; });
- if (!r.ok) { alert(j.error || r.statusText); return; }
- location.reload();
+ if (!r.ok) { alert(j.error || r.statusText); return false; }
+ return true;
}
document.querySelectorAll('[data-disable]').forEach(function(btn) {
- btn.onclick = function() {
+ btn.onclick = async function() {
var name = btn.getAttribute('data-disable');
if (!name || name === window.__USERS_SELF__) return;
if (!confirm('Disable ' + name + '? They will be signed out and cannot log in until re-enabled.')) return;
- patchUser(name, true);
+ if (await patchUser(name, { disabled: true })) location.reload();
};
});
document.querySelectorAll('[data-enable]').forEach(function(btn) {
- btn.onclick = function() {
+ btn.onclick = async function() {
var name = btn.getAttribute('data-enable');
if (!name) return;
- patchUser(name, false);
+ if (await patchUser(name, { disabled: false })) location.reload();
};
});
+document.querySelectorAll('.user-role-select').forEach(function(sel) {
+ var initial = sel.value;
+ sel.addEventListener('change', async function() {
+ var name = sel.getAttribute('data-username');
+ var role = sel.value;
+ if (!name || name === window.__USERS_SELF__) { sel.value = initial; return; }
+ var label = role === 'admin' ? 'Admin' : 'Operator';
+ if (!confirm('Change ' + name + ' to ' + label + '?')) { sel.value = initial; return; }
+ if (await patchUser(name, { role: role })) {
+ initial = role;
+ location.reload();
+ } else {
+ sel.value = initial;
+ }
+ });
+});
`
s.writeAppPage(w, u, role, "users", "Users", body, nil)
}
@@ -240,7 +280,7 @@ func (s *Server) handleAPIUserByUsername(w http.ResponseWriter, r *http.Request)
return
}
target := strings.TrimSpace(mux.Vars(r)["username"])
- if target == "" {
+ if target == "" || !isValidUsername(target) {
jsonError(w, http.StatusBadRequest, "username required")
return
}
@@ -249,13 +289,14 @@ func (s *Server) handleAPIUserByUsername(w http.ResponseWriter, r *http.Request)
return
}
var req struct {
- Disabled *bool `json:"disabled"`
+ Disabled *bool `json:"disabled"`
+ Role *string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid json")
return
}
- if req.Disabled == nil {
+ if req.Disabled == nil && req.Role == nil {
jsonError(w, http.StatusBadRequest, "no changes")
return
}
@@ -270,7 +311,54 @@ func (s *Server) handleAPIUserByUsername(w http.ResponseWriter, r *http.Request)
jsonError(w, http.StatusInternalServerError, "lookup failed")
return
}
- if *req.Disabled && isAdmin(effectivePortalRole(targetOp)) {
+ resp := map[string]interface{}{"ok": true, "username": target}
+ currentRole := effectivePortalRole(targetOp)
+
+ if req.Role != nil {
+ newRole := strings.ToLower(strings.TrimSpace(*req.Role))
+ if newRole != dbconnections.RoleAdmin && newRole != dbconnections.RoleOperator {
+ jsonError(w, http.StatusBadRequest, "role must be admin or operator")
+ return
+ }
+ if newRole != currentRole {
+ if currentRole == dbconnections.RoleAdmin && newRole == dbconnections.RoleOperator && !dbconnections.OperatorIsDisabled(targetOp) {
+ n, err := dbconnections.CountActiveAdminsExcluding(ctx, target)
+ if err != nil {
+ log.Printf("admin: count active admins: %v", err)
+ jsonError(w, http.StatusInternalServerError, "failed")
+ return
+ }
+ if n == 0 {
+ jsonError(w, http.StatusBadRequest, "cannot demote the last active administrator")
+ return
+ }
+ }
+ if err := dbconnections.SetOperatorRole(ctx, target, newRole); err != nil {
+ if errors.Is(err, mongo.ErrNoDocuments) {
+ jsonError(w, http.StatusNotFound, "user not found")
+ return
+ }
+ jsonError(w, http.StatusInternalServerError, "failed to update role")
+ return
+ }
+ if aerr := dbconnections.InsertAuditLog(ctx, adminUser, dbconnections.AuditActionUserRoleUpdated, bson.M{
+ "target_username": target,
+ "old_role": currentRole,
+ "new_role": newRole,
+ }, ""); aerr != nil {
+ log.Printf("admin: audit user role: %v", aerr)
+ }
+ currentRole = newRole
+ resp["role"] = newRole
+ }
+ }
+
+ if req.Disabled == nil {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(resp)
+ return
+ }
+ if *req.Disabled && currentRole == dbconnections.RoleAdmin && !dbconnections.OperatorIsDisabled(targetOp) {
n, err := dbconnections.CountActiveAdminsExcluding(ctx, target)
if err != nil {
log.Printf("admin: count active admins: %v", err)
@@ -299,8 +387,9 @@ func (s *Server) handleAPIUserByUsername(w http.ResponseWriter, r *http.Request)
if aerr := dbconnections.InsertAuditLog(ctx, adminUser, action, bson.M{"target_username": target}, ""); aerr != nil {
log.Printf("admin: audit user enable/disable: %v", aerr)
}
+ resp["disabled"] = *req.Disabled
w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "username": target, "disabled": *req.Disabled})
+ _ = json.NewEncoder(w).Encode(resp)
}
func isValidUsername(s string) bool {
diff --git a/pkg/ai/openai_compat.go b/pkg/ai/openai_compat.go
index 0958ef4..430d9c0 100644
--- a/pkg/ai/openai_compat.go
+++ b/pkg/ai/openai_compat.go
@@ -60,8 +60,10 @@ func chatOpenAICompatible(ctx context.Context, cfg ProviderSettings, system stri
var parsed struct {
Choices []struct {
- Message struct {
- Content string `json:"content"`
+ FinishReason string `json:"finish_reason"`
+ Message struct {
+ Content json.RawMessage `json:"content"`
+ Refusal *string `json:"refusal"`
} `json:"message"`
} `json:"choices"`
Error *struct {
@@ -77,7 +79,60 @@ func chatOpenAICompatible(ctx context.Context, cfg ProviderSettings, system stri
if len(parsed.Choices) == 0 {
return "", fmt.Errorf("%s: empty response", cfg.Label)
}
- return strings.TrimSpace(parsed.Choices[0].Message.Content), nil
+ choice := parsed.Choices[0]
+ if choice.Message.Refusal != nil {
+ if s := strings.TrimSpace(*choice.Message.Refusal); s != "" {
+ return "", fmt.Errorf("%s: model refused: %s", cfg.Label, s)
+ }
+ }
+ content, err := extractOpenAIMessageContent(choice.Message.Content)
+ if err != nil {
+ return "", fmt.Errorf("parse %s response content: %w", cfg.Label, err)
+ }
+ if content == "" {
+ return "", fmt.Errorf("%s: %s", cfg.Label, openAICompatEmptyReplyError(choice.FinishReason, useAzureOpenAICompatMaxCompletion(cfg)))
+ }
+ return content, nil
+}
+
+// extractOpenAIMessageContent handles OpenAI-compatible message content as a string or
+// an array of {type,text} parts (Azure GPT-5 / newer SKUs may return the latter).
+func extractOpenAIMessageContent(raw json.RawMessage) (string, error) {
+ if len(raw) == 0 || string(raw) == "null" {
+ return "", nil
+ }
+ var s string
+ if err := json.Unmarshal(raw, &s); err == nil {
+ return strings.TrimSpace(s), nil
+ }
+ var parts []struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ }
+ if err := json.Unmarshal(raw, &parts); err == nil {
+ var out []string
+ for _, p := range parts {
+ if t := strings.TrimSpace(p.Text); t != "" {
+ out = append(out, t)
+ }
+ }
+ return strings.TrimSpace(strings.Join(out, "\n")), nil
+ }
+ return "", fmt.Errorf("unexpected content shape")
+}
+
+func openAICompatEmptyReplyError(finishReason string, azureReasoning bool) string {
+ switch strings.ToLower(strings.TrimSpace(finishReason)) {
+ case "length":
+ if azureReasoning {
+ return "model returned no visible text (output token budget exhausted; reasoning tokens count toward max_completion_tokens). Increase REAPER_AI_MAX_TOKENS or retry with a shorter prompt"
+ }
+ return "model returned no visible text (output token budget exhausted). Increase REAPER_AI_MAX_TOKENS or retry with a shorter prompt"
+ case "content_filter":
+ return "response blocked by content filter"
+ default:
+ return "empty message content"
+ }
}
// useAzureOpenAICompatMaxCompletion is true for Azure AI Foundry / Azure OpenAI inference
diff --git a/pkg/ai/openai_compat_test.go b/pkg/ai/openai_compat_test.go
new file mode 100644
index 0000000..3b0969a
--- /dev/null
+++ b/pkg/ai/openai_compat_test.go
@@ -0,0 +1,98 @@
+package ai
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestExtractOpenAIMessageContent_string(t *testing.T) {
+ got, err := extractOpenAIMessageContent(json.RawMessage(`"hello world"`))
+ if err != nil || got != "hello world" {
+ t.Fatalf("got %q err %v", got, err)
+ }
+}
+
+func TestExtractOpenAIMessageContent_array(t *testing.T) {
+ got, err := extractOpenAIMessageContent(json.RawMessage(`[{"type":"text","text":"line one"},{"type":"text","text":"line two"}]`))
+ if err != nil || got != "line one\nline two" {
+ t.Fatalf("got %q err %v", got, err)
+ }
+}
+
+func TestExtractOpenAIMessageContent_null(t *testing.T) {
+ got, err := extractOpenAIMessageContent(json.RawMessage(`null`))
+ if err != nil || got != "" {
+ t.Fatalf("got %q err %v", got, err)
+ }
+}
+
+func TestOpenAICompatEmptyReplyError_length(t *testing.T) {
+ msg := openAICompatEmptyReplyError("length", true)
+ if msg == "" || !strings.Contains(msg, "REAPER_AI_MAX_TOKENS") {
+ t.Fatalf("unexpected message: %q", msg)
+ }
+}
+
+func TestChatOpenAICompatible_parsesArrayContent(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _ = json.NewEncoder(w).Encode(map[string]interface{}{
+ "choices": []map[string]interface{}{
+ {
+ "finish_reason": "stop",
+ "message": map[string]interface{}{
+ "role": "assistant",
+ "content": []map[string]string{{"type": "text", "text": "Azure reply"}},
+ },
+ },
+ },
+ })
+ }))
+ defer srv.Close()
+
+ cfg := ProviderSettings{
+ ID: ProviderOpenAI,
+ Label: "OpenAI",
+ APIURL: srv.URL,
+ APIKey: "test",
+ Model: "gpt-4o",
+ MaxTokens: 256,
+ }
+ reply, err := chatOpenAICompatible(context.Background(), cfg, "sys", []Message{{Role: "user", Content: "hi"}})
+ if err != nil || reply != "Azure reply" {
+ t.Fatalf("reply=%q err=%v", reply, err)
+ }
+}
+
+func TestChatOpenAICompatible_emptyContentIsError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _ = json.NewEncoder(w).Encode(map[string]interface{}{
+ "choices": []map[string]interface{}{
+ {
+ "finish_reason": "length",
+ "message": map[string]interface{}{
+ "role": "assistant",
+ "content": "",
+ },
+ },
+ },
+ })
+ }))
+ defer srv.Close()
+
+ cfg := ProviderSettings{
+ ID: ProviderFoundry,
+ Label: "Azure AI Foundry",
+ APIURL: srv.URL,
+ APIKey: "test",
+ Model: "gpt-5.5",
+ MaxTokens: 2048,
+ }
+ _, err := chatOpenAICompatible(context.Background(), cfg, "sys", []Message{{Role: "user", Content: "hi"}})
+ if err == nil {
+ t.Fatal("expected error for empty content")
+ }
+}
diff --git a/pkg/dbconnections/admin.go b/pkg/dbconnections/admin.go
index 275f0ce..8f75467 100644
--- a/pkg/dbconnections/admin.go
+++ b/pkg/dbconnections/admin.go
@@ -14,6 +14,7 @@ import (
const (
collectionOperators = "operators"
collectionOperatorSessions = "operator_sessions"
+ maxOperatorUsernameLen = 128
)
// OperatorsCollection holds admin users for the web panel.
@@ -28,6 +29,56 @@ const (
RoleOperator = "operator"
)
+// canonicalOperatorUsername returns an allowlisted username copy.
+// Rebuilding rune-by-rune (instead of returning the trimmed input) ensures Mongo
+// filters never receive a direct user-controlled string (CodeQL go/sql-injection /
+// go/nosql-injection on bson.M{"username": ...}).
+func canonicalOperatorUsername(username string) (string, bool) {
+ username = strings.TrimSpace(username)
+ if username == "" || len(username) > maxOperatorUsernameLen {
+ return "", false
+ }
+ var b strings.Builder
+ b.Grow(len(username))
+ for _, r := range username {
+ switch {
+ case r >= 'a' && r <= 'z':
+ case r >= 'A' && r <= 'Z':
+ case r >= '0' && r <= '9':
+ case r == '.' || r == '_' || r == '-':
+ default:
+ return "", false
+ }
+ b.WriteRune(r)
+ }
+ out := b.String()
+ if out == "" {
+ return "", false
+ }
+ return out, true
+}
+
+// operatorUsernameFilter builds an equality filter with an explicit $eq so the
+// username value cannot be interpreted as a MongoDB operator document.
+func operatorUsernameFilter(username string) (bson.M, bool) {
+ safe, ok := canonicalOperatorUsername(username)
+ if !ok {
+ return nil, false
+ }
+ return bson.M{"username": bson.M{"$eq": safe}}, true
+}
+
+func canonicalOperatorRole(role string) (string, bool) {
+ switch strings.ToLower(strings.TrimSpace(role)) {
+ case RoleAdmin:
+ return RoleAdmin, true
+ case RoleOperator:
+ return RoleOperator, true
+ default:
+ return "", false
+ }
+}
+
// Operator is a human operator account for the admin panel.
type Operator struct {
Username string `bson:"username"`
@@ -104,8 +155,12 @@ func InsertOperator(ctx context.Context, op Operator) error {
// FindOperatorByUsername loads an operator by username.
func FindOperatorByUsername(ctx context.Context, username string) (*Operator, error) {
+ filter, ok := operatorUsernameFilter(username)
+ if !ok {
+ return nil, mongo.ErrNoDocuments
+ }
var op Operator
- err := OperatorsCollection.FindOne(ctx, bson.M{"username": username}).Decode(&op)
+ err := OperatorsCollection.FindOne(ctx, filter).Decode(&op)
if err != nil {
return nil, err
}
@@ -161,13 +216,13 @@ func DeleteSession(ctx context.Context, token string) error {
// DeleteSessionsForUsername removes all sessions for an operator (e.g. after disable).
func DeleteSessionsForUsername(ctx context.Context, username string) error {
- username = strings.TrimSpace(username)
- if username == "" {
+ filter, ok := operatorUsernameFilter(username)
+ if !ok {
return nil
}
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
- _, err := OperatorSessionsCollection.DeleteMany(ctx, bson.M{"username": username})
+ _, err := OperatorSessionsCollection.DeleteMany(ctx, filter)
return err
}
@@ -206,15 +261,37 @@ func CountActiveAdminsExcluding(ctx context.Context, excludeUsername string) (in
return n, cur.Err()
}
+// SetOperatorRole sets the portal role (admin | operator).
+func SetOperatorRole(ctx context.Context, username, role string) error {
+ filter, ok := operatorUsernameFilter(username)
+ if !ok {
+ return mongo.ErrNoDocuments
+ }
+ storedRole, ok := canonicalOperatorRole(role)
+ if !ok {
+ return mongo.ErrNoDocuments
+ }
+ ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ defer cancel()
+ res, err := OperatorsCollection.UpdateOne(ctx, filter, bson.M{"$set": bson.M{"role": storedRole}})
+ if err != nil {
+ return err
+ }
+ if res.MatchedCount == 0 {
+ return mongo.ErrNoDocuments
+ }
+ return nil
+}
+
// SetOperatorDisabled sets disabled flag and returns mongo.ErrNoDocuments if user missing.
func SetOperatorDisabled(ctx context.Context, username string, disabled bool) error {
- username = strings.TrimSpace(username)
- if username == "" {
+ filter, ok := operatorUsernameFilter(username)
+ if !ok {
return mongo.ErrNoDocuments
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
- res, err := OperatorsCollection.UpdateOne(ctx, bson.M{"username": username}, bson.M{"$set": bson.M{"disabled": disabled}})
+ res, err := OperatorsCollection.UpdateOne(ctx, filter, bson.M{"$set": bson.M{"disabled": disabled}})
if err != nil {
return err
}
@@ -226,13 +303,13 @@ func SetOperatorDisabled(ctx context.Context, username string, disabled bool) er
// UpdateOperatorPasswordHash sets a new password hash for an operator.
func UpdateOperatorPasswordHash(ctx context.Context, username, passwordHash string) error {
- username = strings.TrimSpace(username)
- if username == "" || passwordHash == "" {
+ filter, ok := operatorUsernameFilter(username)
+ if !ok || passwordHash == "" {
return mongo.ErrNoDocuments
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
- res, err := OperatorsCollection.UpdateOne(ctx, bson.M{"username": username}, bson.M{"$set": bson.M{"password_hash": passwordHash}})
+ res, err := OperatorsCollection.UpdateOne(ctx, filter, bson.M{"$set": bson.M{"password_hash": passwordHash}})
if err != nil {
return err
}
@@ -244,13 +321,13 @@ func UpdateOperatorPasswordHash(ctx context.Context, username, passwordHash stri
// SetOperatorTotpPending stores a base32 secret for enrollment (not yet active).
func SetOperatorTotpPending(ctx context.Context, username, pendingSecretBase32 string) error {
- username = strings.TrimSpace(username)
- if username == "" || pendingSecretBase32 == "" {
+ filter, ok := operatorUsernameFilter(username)
+ if !ok || pendingSecretBase32 == "" {
return mongo.ErrNoDocuments
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
- res, err := OperatorsCollection.UpdateOne(ctx, bson.M{"username": username}, bson.M{"$set": bson.M{"totp_pending_secret": pendingSecretBase32}})
+ res, err := OperatorsCollection.UpdateOne(ctx, filter, bson.M{"$set": bson.M{"totp_pending_secret": pendingSecretBase32}})
if err != nil {
return err
}
@@ -262,21 +339,21 @@ func SetOperatorTotpPending(ctx context.Context, username, pendingSecretBase32 s
// ConfirmOperatorTotp promotes totp_pending_secret to totp_secret and enables MFA.
func ConfirmOperatorTotp(ctx context.Context, username string) error {
- username = strings.TrimSpace(username)
- if username == "" {
+ filter, ok := operatorUsernameFilter(username)
+ if !ok {
return mongo.ErrNoDocuments
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
var op Operator
- err := OperatorsCollection.FindOne(ctx, bson.M{"username": username}).Decode(&op)
+ err := OperatorsCollection.FindOne(ctx, filter).Decode(&op)
if err != nil {
return err
}
if strings.TrimSpace(op.TotpPendingSecret) == "" {
return mongo.ErrNoDocuments
}
- _, err = OperatorsCollection.UpdateOne(ctx, bson.M{"username": username}, bson.M{
+ _, err = OperatorsCollection.UpdateOne(ctx, filter, bson.M{
"$set": bson.M{
"totp_secret": op.TotpPendingSecret,
"totp_enabled": true,
@@ -288,13 +365,13 @@ func ConfirmOperatorTotp(ctx context.Context, username string) error {
// DisableOperatorTotp turns off MFA and clears secrets.
func DisableOperatorTotp(ctx context.Context, username string) error {
- username = strings.TrimSpace(username)
- if username == "" {
+ filter, ok := operatorUsernameFilter(username)
+ if !ok {
return mongo.ErrNoDocuments
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
- res, err := OperatorsCollection.UpdateOne(ctx, bson.M{"username": username}, bson.M{
+ res, err := OperatorsCollection.UpdateOne(ctx, filter, bson.M{
"$set": bson.M{"totp_enabled": false},
"$unset": bson.M{
"totp_secret": "",
@@ -312,13 +389,13 @@ func DisableOperatorTotp(ctx context.Context, username string) error {
// ClearOperatorTotpPending abandons in-progress enrollment.
func ClearOperatorTotpPending(ctx context.Context, username string) error {
- username = strings.TrimSpace(username)
- if username == "" {
+ filter, ok := operatorUsernameFilter(username)
+ if !ok {
return mongo.ErrNoDocuments
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
- _, err := OperatorsCollection.UpdateOne(ctx, bson.M{"username": username}, bson.M{"$unset": bson.M{"totp_pending_secret": ""}})
+ _, err := OperatorsCollection.UpdateOne(ctx, filter, bson.M{"$unset": bson.M{"totp_pending_secret": ""}})
return err
}
diff --git a/pkg/dbconnections/admin_role_test.go b/pkg/dbconnections/admin_role_test.go
new file mode 100644
index 0000000..3bdb595
--- /dev/null
+++ b/pkg/dbconnections/admin_role_test.go
@@ -0,0 +1,63 @@
+package dbconnections
+
+import (
+ "testing"
+
+ "go.mongodb.org/mongo-driver/bson"
+)
+
+func TestCanonicalOperatorUsername(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ in string
+ want string
+ ok bool
+ }{
+ {"alice", "alice", true},
+ {" Alice.1_b-c ", "Alice.1_b-c", true},
+ {"", "", false},
+ {" ", "", false},
+ {"bad$name", "", false},
+ {"alice{$ne:1}", "", false},
+ {`{"$ne":""}`, "", false},
+ }
+ for _, tc := range cases {
+ got, ok := canonicalOperatorUsername(tc.in)
+ if ok != tc.ok || got != tc.want {
+ t.Fatalf("canonicalOperatorUsername(%q) = (%q, %v), want (%q, %v)", tc.in, got, ok, tc.want, tc.ok)
+ }
+ }
+}
+
+func TestCanonicalOperatorRole(t *testing.T) {
+ t.Parallel()
+ got, ok := canonicalOperatorRole(" ADMIN ")
+ if !ok || got != RoleAdmin {
+ t.Fatalf("got %q ok=%v", got, ok)
+ }
+ got, ok = canonicalOperatorRole("operator")
+ if !ok || got != RoleOperator {
+ t.Fatalf("got %q ok=%v", got, ok)
+ }
+ if _, ok := canonicalOperatorRole("root"); ok {
+ t.Fatal("expected reject")
+ }
+}
+
+func TestOperatorUsernameFilterUsesEq(t *testing.T) {
+ t.Parallel()
+ filter, ok := operatorUsernameFilter("alice")
+ if !ok {
+ t.Fatal("expected ok")
+ }
+ inner, ok := filter["username"].(bson.M)
+ if !ok {
+ t.Fatalf("username filter type %T", filter["username"])
+ }
+ if inner["$eq"] != "alice" {
+ t.Fatalf("expected $eq alice, got %#v", inner)
+ }
+ if _, ok := operatorUsernameFilter(`{"$gt":""}`); ok {
+ t.Fatal("operator-looking username must be rejected")
+ }
+}
diff --git a/pkg/dbconnections/audit.go b/pkg/dbconnections/audit.go
index 8f71caf..f0305fd 100644
--- a/pkg/dbconnections/audit.go
+++ b/pkg/dbconnections/audit.go
@@ -29,6 +29,7 @@ const (
AuditActionTotpDisabled = "totp_disabled"
AuditActionUserDisabled = "user_disabled"
AuditActionUserEnabled = "user_enabled"
+ AuditActionUserRoleUpdated = "user_role_updated"
AuditActionEngagementOperatorsUpdated = "engagement_operators_updated"
AuditActionAIChat = "ai_chat"
)
diff --git a/pkg/dbconnections/mfa_challenge.go b/pkg/dbconnections/mfa_challenge.go
index d1e51e7..56260da 100644
--- a/pkg/dbconnections/mfa_challenge.go
+++ b/pkg/dbconnections/mfa_challenge.go
@@ -72,11 +72,12 @@ func PeekMFAChallenge(ctx context.Context, token string) (username string, err e
// DeleteMFAChallengesForUser removes any pending login challenges for a user (e.g. after password change).
func DeleteMFAChallengesForUser(ctx context.Context, username string) error {
- if username == "" {
+ filter, ok := operatorUsernameFilter(username)
+ if !ok {
return nil
}
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
- _, err := MFAChallengesCollection.DeleteMany(ctx, bson.M{"username": username})
+ _, err := MFAChallengesCollection.DeleteMany(ctx, filter)
return err
}