Files
ARTEX/server/task_credentials.go
T
BaaRai c177ab9d86 feat(tasks): per-task test-account credentials
Task credentials (#3): declare test accounts at task creation, and never
lose the accounts the agent registers on its own.

- task_credentials table (unique per task+domain+username; upsert
  overwrites the password in place), idempotent schema migration
- POST /api/tasks accepts credentials; GET/POST/DELETE
  /api/tasks/{id}/credentials serve the UI (manual add, e.g. relaying an
  OTP-verified account; delete)
- empty password = the "agent generates" mode: the entry surfaces in the
  worker's graph overview with an explicit instruction to register that
  account with a strong password and record it
- new tools save_task_credential / list_task_credentials for worker and
  pentest; the graph overview injects the task's credentials each round
  with a standing note: reuse matching entries, always record accounts
  you register
- New Task dialog: optional credentials section (domain, username,
  password, note) with a dedicated-test-accounts hint
- task detail: Credentials tab, passwords masked with explicit
  reveal/copy, agent-recorded badge, light polling so mid-run recordings
  appear

One-shot binding migration (settings-flag-guarded, same pattern as the
other seeds) grants the two tools to worker+pentest on databases seeded
before this change; fresh DBs get them via WorkerTools()/the pentest
catalog.

Closes #3
2026-10-09 14:28:08 +02:00

95 lines
2.6 KiB
Go

package server
import (
"encoding/json"
"net/http"
"strconv"
"github.com/Autumn-27/artex/db"
)
// Task credential HTTP handlers (#3). The agent-side write path is the
// save_task_credential tool; these routes serve the UI (task detail tab) and the
// creation dialog.
// taskCredentialDTO masks nothing — the admin is the credential owner — but the
// frontend renders passwords masked with an explicit reveal.
func taskCredentialDTO(c db.TaskCredential) map[string]any {
return map[string]any{
"id": c.ID, "task_id": c.TaskID, "domain": c.Domain, "username": c.Username,
"password": c.Password, "source": c.Source, "note": c.Note,
"created_at": c.CreatedAt, "updated_at": c.UpdatedAt,
}
}
func (s *Server) taskCredentialTaskID(w http.ResponseWriter, r *http.Request) (int64, bool) {
t, ok := s.m.Task(r.PathValue("id"))
if !ok {
writeErr(w, 404, "task not found")
return 0, false
}
id, err := strconv.ParseInt(t.ID, 10, 64)
if err != nil || id <= 0 {
writeErr(w, 500, "task id is not numeric")
return 0, false
}
return id, true
}
func (s *Server) listTaskCredentials(w http.ResponseWriter, r *http.Request) {
id, ok := s.taskCredentialTaskID(w, r)
if !ok {
return
}
list, err := s.m.PG().TaskCredentials(id)
if err != nil {
writeErr(w, 500, err.Error())
return
}
out := make([]map[string]any, 0, len(list))
for _, c := range list {
out = append(out, taskCredentialDTO(c))
}
writeJSON(w, 200, out)
}
func (s *Server) addTaskCredential(w http.ResponseWriter, r *http.Request) {
id, ok := s.taskCredentialTaskID(w, r)
if !ok {
return
}
var req taskCredentialReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, 400, "bad json: "+err.Error())
return
}
// source=user: manual adds from the UI (e.g. relaying an OTP-verified account).
// An empty password stays allowed here — the "agent generates" mode.
c, err := s.m.PG().UpsertTaskCredential(id, db.TaskCredentialInput{
Domain: req.Domain, Username: req.Username, Password: req.Password,
Source: "user", Note: req.Note,
})
if err != nil {
writeErr(w, 400, err.Error())
return
}
writeJSON(w, 201, taskCredentialDTO(c))
}
func (s *Server) deleteTaskCredential(w http.ResponseWriter, r *http.Request) {
id, ok := s.taskCredentialTaskID(w, r)
if !ok {
return
}
cid, err := strconv.ParseInt(r.PathValue("cid"), 10, 64)
if err != nil || cid <= 0 {
writeErr(w, 400, "invalid credential id")
return
}
if err := s.m.PG().DeleteTaskCredential(id, cid); err != nil {
writeErr(w, 404, err.Error())
return
}
writeJSON(w, 200, map[string]any{"ok": true})
}