diff --git a/README.md b/README.md index 2b0c632..6f2e945 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ The console signs people in with an email and password. While no admin exists, t An admin adds the next person from the console. That person gets a temporary password, shown once, and chooses their own at the next sign-in. SMTP is optional. When it is set, the temporary password is emailed, and a forgotten password can be reset from a one-hour link. Google and GitHub buttons appear only when those client credentials are set, and the provider email must match an existing admin. Every console account is an admin. Two-factor authentication is optional per admin. -Each admin gets a personal API token, shown once in the console. Collectors, CI, and `curl` send it as `Authorization: Bearer` or `X-API-Key` on every `/v1` call except health, the session probe, and logout. The CLI talks to Postgres and does not use the token. A token cannot add or remove admins. +An API token is created only when an admin asks for one, from the console or with `om token create --email you@example.com`. It is printed once. Collectors, CI, and `curl` send it as `Authorization: Bearer` or `X-API-Key` on every `/v1` call except health, the session probe, and logout. The CLI talks to Postgres and does not use the token. A token cannot add or remove admins. `om token rotate --email you@example.com` replaces it. **API endpoints:** `GET /v1/health`, `POST /v1/ingest`, `GET /v1/findings`, `GET /v1/graph/snapshot`, `POST /v1/rules/run`, `GET /v1/identity/blast-radius` diff --git a/api/README.md b/api/README.md index 10aa6b9..f6fe98b 100644 --- a/api/README.md +++ b/api/README.md @@ -14,4 +14,4 @@ HTTP and GraphQL API for the web UI, collectors, and integrations. - `GET /v1/findings` — prioritized findings with path context - `GET /v1/health` — readiness for orchestration -Authentication: every `/v1/*` route except `GET /v1/health`, `GET /v1/auth/session`, `POST /v1/auth/logout`, `POST /v1/auth/setup`, `POST /v1/auth/login`, and `POST /v1/auth/totp` requires a console session cookie or that admin's personal API token (`Authorization: Bearer` or `X-API-Key`). Admin routes (`/v1/admins`, including token rotation) require the session. People sign in with email and password. The first account is created from the setup form or `om admin create`. Google and GitHub are optional and must match an existing admin email. Enterprise SAML and custom identity providers live outside this repo. +Authentication: every `/v1/*` route except `GET /v1/health`, `GET /v1/auth/session`, `POST /v1/auth/logout`, `POST /v1/auth/setup`, `POST /v1/auth/login`, and `POST /v1/auth/totp` requires a console session cookie or an API token that admin created (`Authorization: Bearer` or `X-API-Key`). Admin routes (`/v1/admins`, including token create and rotation) require the session. People sign in with email and password. The first account is created from the setup form or `om admin create`. A token is created from the console or `om token create`, and is not issued at sign-in. Google and GitHub are optional and must match an existing admin email. Enterprise SAML and custom identity providers live outside this repo. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7922cae..1331942 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -76,7 +76,7 @@ The free OSS core focuses on **defending against external attackers** — unders | Community collectors and rule packs | Enterprise compliance workflows | | Cloud audit ingest as graph context | Immutable platform audit logs (who used OpenSourceOM) | -Console login in the OSS core is email and password. Google and GitHub are optional. The first account is created from the setup form or `om admin create`, and later admins get a temporary password. Every signed-in account is an admin and receives a personal API token for scripts. Bring-your-own identity providers stay commercial. +Console login in the OSS core is email and password. Google and GitHub are optional. The first account is created from the setup form or `om admin create`, and later admins get a temporary password. Every signed-in account is an admin. A personal API token for scripts is created only when that admin asks for one. Bring-your-own identity providers stay commercial. Cloud provider audit APIs (for example CloudTrail) are Phase 3 graph context ([#33](https://github.com/OpenSourceOM/core/issues/33)): evidence of exposure and attack paths. **Platform audit logs** — operator actions in the console/API, SSO identity, retention, and auditor export — belong in the commercial offering. diff --git a/docs/adr/003-phase2-cspm-rbac.md b/docs/adr/003-phase2-cspm-rbac.md index 65e7a3e..2c4943c 100644 --- a/docs/adr/003-phase2-cspm-rbac.md +++ b/docs/adr/003-phase2-cspm-rbac.md @@ -43,7 +43,7 @@ Multi-tenant RBAC (admin/viewer roles, account scoping) was considered for Phase - Every `/v1/*` route except `GET /v1/health` requires `Authorization: Bearer` or `X-API-Key`. - An empty secret leaves the API open for local development. Production and the Helm chart set the secret. -- [ADR 005](./005-console-admins.md) replaces the shared secret with a console session and a personal API token per admin. Admin management requires that session. +- [ADR 005](./005-console-admins.md) replaces the shared secret with a console session. An admin can create a personal API token for scripts. Admin management requires that session. ## Consequences diff --git a/docs/adr/005-console-admins.md b/docs/adr/005-console-admins.md index f6a717d..846f72d 100644 --- a/docs/adr/005-console-admins.md +++ b/docs/adr/005-console-admins.md @@ -23,7 +23,7 @@ People using the console need a login. Scripts that call the HTTP API need a cre - Each admin can turn on TOTP two-factor authentication. Recovery codes are shown once. It is not required to finish setup. - Google accounts must have a verified email. GitHub login requests `user:email` and accepts any verified address. - A successful login sets an httpOnly session cookie. The cookie is `Secure` when `OM_PUBLIC_URL` is `https`. Sessions last 7 days. -- Creating an admin also creates a personal API token. The database stores only the hash. The raw value is shown once, in the console after that first session. Scripts send it as `Authorization: Bearer` or `X-API-Key`. Rotating the token returns the new value once and invalidates the old one. Removing an admin revokes their token. +- Creating an admin does not create an API token. An admin creates one from the console, or from the machine with `om token create --email`. The database stores only the hash. The raw value is shown once, in that response. Scripts send it as `Authorization: Bearer` or `X-API-Key`. Rotating the token, from the console or `om token rotate --email`, returns the new value once and invalidates the old one. Removing an admin revokes their token. - Every console account is an admin. There is no role column. The last admin cannot be removed. - `GET /v1/admins`, `POST /v1/admins`, `POST /v1/admins/token`, and `DELETE /v1/admins/{id}` require a session. A personal token cannot create, rotate, or remove admins. - Other `/v1` routes accept that personal token or a session, except health, `GET /v1/auth/session`, and `POST /v1/auth/logout`. There is no shared secret and no open mode. @@ -34,7 +34,7 @@ People using the console need a login. Scripts that call the HTTP API need a cre - Password login works with no identity provider and no mail server. Google, GitHub, and SMTP are optional. - Whoever can reach the API before the first admin exists can claim the install. Keep that port private, or create the admin with `om admin create` first. -- An air-gapped console can sign in with a password. Scripts need a personal token from an admin who has signed in. +- An air-gapped console can sign in with a password. Scripts need a token created from the console or with `om token create`. - Platform audit logs of who used the console remain a commercial concern. This ADR records who the admins are, not a history of their actions. ## References diff --git a/internal/api/console_auth.go b/internal/api/console_auth.go index da62d90..65e9d26 100644 --- a/internal/api/console_auth.go +++ b/internal/api/console_auth.go @@ -80,17 +80,16 @@ func (s *Server) handleAuthSession(w http.ResponseWriter, r *http.Request) { } if admin, ok := s.currentAdmin(r); ok { body["authenticated"] = true - body["admin"] = adminView(admin) + view := adminView(admin) if !admin.MustChangePassword { - preview, err := s.admins.TakeTokenPreview(r.Context(), admin.ID) + hasToken, err := s.admins.HasToken(r.Context(), admin.ID) if err != nil { writeAdminStoreError(w, err) return } - if preview != "" { - body["api_token"] = preview - } + view["has_api_token"] = hasToken } + body["admin"] = view } writeJSON(w, http.StatusOK, body) } @@ -162,6 +161,24 @@ func (s *Server) handleCreateAdmin(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) handleCreateToken(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + admin, ok := s.requireAdmin(w, r) + if !ok { + return + } + token, err := s.admins.CreateToken(r.Context(), admin.ID) + if errors.Is(err, auth.ErrTokenExists) { + writeJSON(w, http.StatusConflict, map[string]string{"error": "An API token already exists. Rotate it to replace the current one."}) + return + } + if err != nil { + writeAdminStoreError(w, err) + return + } + writeJSON(w, http.StatusCreated, map[string]string{"token": token}) +} + func (s *Server) handleRotateToken(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") admin, ok := s.requireAdmin(w, r) diff --git a/internal/api/console_auth_test.go b/internal/api/console_auth_test.go index f60168a..92662ca 100644 --- a/internal/api/console_auth_test.go +++ b/internal/api/console_auth_test.go @@ -51,10 +51,25 @@ func TestConsoleLoginCreatesAdminSession(t *testing.T) { sessionProbe.AddCookie(session) sessionRec := httptest.NewRecorder() handler.ServeHTTP(sessionRec, sessionProbe) - if sessionRec.Code != http.StatusOK || !strings.Contains(sessionRec.Body.String(), `"api_token"`) { + if sessionRec.Code != http.StatusOK || strings.Contains(sessionRec.Body.String(), `"api_token"`) || !strings.Contains(sessionRec.Body.String(), `"has_api_token":false`) { t.Fatalf("session = %d %s", sessionRec.Code, sessionRec.Body.String()) } + createToken := httptest.NewRequest(http.MethodPost, "/v1/admins/token", nil) + createToken.AddCookie(session) + createTokenRec := httptest.NewRecorder() + handler.ServeHTTP(createTokenRec, createToken) + if createTokenRec.Code != http.StatusCreated || !strings.Contains(createTokenRec.Body.String(), `"token":"personal-token"`) { + t.Fatalf("create token = %d %s", createTokenRec.Code, createTokenRec.Body.String()) + } + againToken := httptest.NewRequest(http.MethodPost, "/v1/admins/token", nil) + againToken.AddCookie(session) + againTokenRec := httptest.NewRecorder() + handler.ServeHTTP(againTokenRec, againToken) + if againTokenRec.Code != http.StatusConflict { + t.Fatalf("second token = %d %s", againTokenRec.Code, againTokenRec.Body.String()) + } + keyOnly := httptest.NewRequest(http.MethodPost, "/v1/admins", strings.NewReader(`{"email":"next@example.com"}`)) keyOnly.Header.Set("X-API-Key", directory.rawToken()) keyRec := httptest.NewRecorder() @@ -71,6 +86,21 @@ func TestConsoleLoginCreatesAdminSession(t *testing.T) { t.Fatalf("token rules status = %d", rulesTokenRec.Code) } + rotateToken := httptest.NewRequest(http.MethodPost, "/v1/admins/token/rotate", nil) + rotateToken.AddCookie(session) + rotateTokenRec := httptest.NewRecorder() + handler.ServeHTTP(rotateTokenRec, rotateToken) + if rotateTokenRec.Code != http.StatusOK || !strings.Contains(rotateTokenRec.Body.String(), `"token":"rotated-token"`) { + t.Fatalf("rotate token = %d %s", rotateTokenRec.Code, rotateTokenRec.Body.String()) + } + oldToken := httptest.NewRequest(http.MethodGet, "/v1/rules", nil) + oldToken.Header.Set("X-API-Key", "personal-token") + oldTokenRec := httptest.NewRecorder() + handler.ServeHTTP(oldTokenRec, oldToken) + if oldTokenRec.Code != http.StatusUnauthorized { + t.Fatalf("old token status = %d", oldTokenRec.Code) + } + invite := httptest.NewRequest(http.MethodPost, "/v1/admins", strings.NewReader(`{"email":"next@example.com"}`)) invite.Header.Set("Content-Type", "application/json") invite.AddCookie(session) @@ -262,7 +292,6 @@ type memoryDirectory struct { invites map[string]memoryInvite sessions map[string]string tokens map[string]string - previews map[string]string passwords map[string]string challenges map[string]string resets map[string]memoryReset @@ -285,7 +314,6 @@ func newMemoryDirectory() *memoryDirectory { invites: map[string]memoryInvite{}, sessions: map[string]string{}, tokens: map[string]string{}, - previews: map[string]string{}, passwords: map[string]string{}, challenges: map[string]string{}, resets: map[string]memoryReset{}, @@ -445,9 +473,6 @@ func (d *memoryDirectory) Bootstrap(_ context.Context, identity auth.Identity) ( Email: identity.Emails[0], } d.admins = append(d.admins, admin) - d.plainToken = "personal-token" - d.tokens[auth.HashToken(d.plainToken)] = admin.ID - d.previews[admin.ID] = d.plainToken return admin, nil } @@ -466,17 +491,63 @@ func (d *memoryDirectory) AdminForToken(_ context.Context, token string) (auth.A return auth.Admin{}, auth.ErrNotFound } -func (d *memoryDirectory) TakeTokenPreview(_ context.Context, adminID string) (string, error) { +func (d *memoryDirectory) HasToken(_ context.Context, adminID string) (bool, error) { + d.mu.Lock() + defer d.mu.Unlock() + for _, id := range d.tokens { + if id == adminID { + return true, nil + } + } + for _, admin := range d.admins { + if admin.ID == adminID { + return false, nil + } + } + return false, auth.ErrNotFound +} + +func (d *memoryDirectory) CreateToken(_ context.Context, adminID string) (string, error) { d.mu.Lock() defer d.mu.Unlock() - preview := d.previews[adminID] - delete(d.previews, adminID) - return preview, nil + found := false + for _, admin := range d.admins { + if admin.ID == adminID { + found = true + break + } + } + if !found { + return "", auth.ErrNotFound + } + for _, id := range d.tokens { + if id == adminID { + return "", auth.ErrTokenExists + } + } + d.plainToken = "personal-token" + d.tokens[auth.HashToken(d.plainToken)] = adminID + return d.plainToken, nil } func (d *memoryDirectory) RotateToken(_ context.Context, adminID string) (string, error) { d.mu.Lock() defer d.mu.Unlock() + found := false + for _, admin := range d.admins { + if admin.ID == adminID { + found = true + break + } + } + if !found { + return "", auth.ErrNotFound + } + for hash, id := range d.tokens { + if id == adminID { + delete(d.tokens, hash) + } + } d.plainToken = "rotated-token" d.tokens[auth.HashToken(d.plainToken)] = adminID return d.plainToken, nil @@ -567,9 +638,6 @@ func (d *memoryDirectory) SetupAdmin(_ context.Context, email, password, pinned admin := auth.Admin{ID: "admin-created", Provider: auth.ProviderPassword, Subject: email, Login: email, Email: email} d.admins = append(d.admins, admin) d.passwords[email] = password - d.plainToken = "personal-token" - d.tokens[auth.HashToken(d.plainToken)] = admin.ID - d.previews[admin.ID] = d.plainToken return admin, nil } diff --git a/internal/api/server.go b/internal/api/server.go index b50c488..b02baf7 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -83,7 +83,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /v1/auth/logout", s.handleLogout) mux.HandleFunc("GET /v1/admins", s.handleListAdmins) mux.HandleFunc("POST /v1/admins", s.handleCreateAdmin) - mux.HandleFunc("POST /v1/admins/token", s.handleRotateToken) + mux.HandleFunc("POST /v1/admins/token", s.handleCreateToken) + mux.HandleFunc("POST /v1/admins/token/rotate", s.handleRotateToken) mux.HandleFunc("DELETE /v1/admins/{id}", s.handleDeleteAdmin) mux.HandleFunc("GET /auth/{provider}/start", s.handleAuthStart) mux.HandleFunc("GET /auth/{provider}/callback", s.handleAuthCallback) diff --git a/internal/api/web/app.js b/internal/api/web/app.js index da125a2..d47d139 100644 --- a/internal/api/web/app.js +++ b/internal/api/web/app.js @@ -109,13 +109,19 @@ function clearConsoleError() { function showToken(token, message) { const banner = document.getElementById("token-banner"); const field = document.getElementById("token-value"); - banner.querySelector("p").textContent = message || "This is your API token. Copy it now. It will not be shown again."; + banner.querySelector("p").textContent = message || "Copy it now. It will not be shown again."; field.value = token; banner.classList.remove("hidden"); field.focus(); field.select(); } +function setTokenButton() { + const button = document.getElementById("token-btn"); + const has = Boolean(currentAdmin && currentAdmin.has_api_token); + button.textContent = has ? "Rotate API token" : "Create API token"; +} + function adminLabel(admin) { const who = admin.login || admin.name || "Admin"; const via = providerLabels[admin.provider] || admin.provider; @@ -462,7 +468,7 @@ function showConsole(session) { if (window.location.pathname !== "/") { window.history.replaceState({}, "", "/"); } - if (session.api_token) showToken(session.api_token); + setTokenButton(); } async function renderAdmins() { @@ -530,22 +536,6 @@ async function renderAdmins() { } }); panel.append(form, status); - - const rotate = document.createElement("button"); - rotate.type = "button"; - rotate.textContent = "Rotate API token"; - rotate.addEventListener("click", async () => { - rotate.disabled = true; - try { - const created = await fetchJSON("/v1/admins/token", { method: "POST" }); - showToken(created.token); - } catch (err) { - showConsoleError(err); - } finally { - rotate.disabled = false; - } - }); - panel.appendChild(rotate); } async function removeAdmin(id) { @@ -823,6 +813,30 @@ document.getElementById("query-select").addEventListener("change", () => { refresh().catch(showConsoleError); }); document.getElementById("rules-btn").addEventListener("click", runRules); +document.getElementById("token-btn").addEventListener("click", async () => { + const button = document.getElementById("token-btn"); + const has = Boolean(currentAdmin && currentAdmin.has_api_token); + if (has && !window.confirm("Replace the current API token? Scripts using it will stop working.")) { + return; + } + button.disabled = true; + try { + const created = await fetchJSON(has ? "/v1/admins/token/rotate" : "/v1/admins/token", { method: "POST" }); + currentAdmin.has_api_token = true; + showToken(created.token, has + ? "This replaces the previous API token. Copy it now. It will not be shown again." + : "This API token is for scripts. Copy it now. It will not be shown again."); + setTokenButton(); + } catch (err) { + showConsoleError(err); + } finally { + button.disabled = false; + } +}); +document.getElementById("token-dismiss").addEventListener("click", () => { + document.getElementById("token-value").value = ""; + document.getElementById("token-banner").classList.add("hidden"); +}); document.getElementById("logout-btn").addEventListener("click", async () => { await fetch("/v1/auth/logout", { method: "POST" }); window.location.href = "/login"; diff --git a/internal/api/web/index.html b/internal/api/web/index.html index 83b9ca5..54435f3 100644 --- a/internal/api/web/index.html +++ b/internal/api/web/index.html @@ -15,6 +15,7 @@

OpenSourceOM