Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
2 changes: 1 addition & 1 deletion api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/adr/003-phase2-cspm-rbac.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions docs/adr/005-console-admins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
27 changes: 22 additions & 5 deletions internal/api/console_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down
94 changes: 81 additions & 13 deletions internal/api/console_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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{},
Expand Down Expand Up @@ -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
}

Expand All @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
3 changes: 2 additions & 1 deletion internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
50 changes: 32 additions & 18 deletions internal/api/web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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";
Expand Down
6 changes: 4 additions & 2 deletions internal/api/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ <h1>OpenSourceOM</h1>
</div>
<div id="account" class="account hidden">
<span id="account-label"></span>
<button id="token-btn" type="button">Create API token</button>
<button id="security-btn" type="button">Security</button>
<button id="admins-btn" type="button">Admins</button>
<button id="logout-btn" type="button">Log out</button>
Expand All @@ -28,8 +29,9 @@ <h2 id="gate-title">Sign in</h2>
</section>

<section id="token-banner" class="token-banner hidden" role="status">
<p>This is your API token. Copy it now. It will not be shown again.</p>
<input id="token-value" readonly aria-label="API token" />
<p>This API token is for scripts. Copy it now. It will not be shown again.</p>
<input id="token-value" readonly aria-label="Secret" />
<button id="token-dismiss" type="button">Dismiss</button>
</section>
<section id="security-panel" class="admins hidden"></section>
<section id="admins-panel" class="admins hidden"></section>
Expand Down
3 changes: 2 additions & 1 deletion internal/api/web/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ header h1 { margin: 0; font-size: 1.4rem; }
padding: 0.45rem 0.6rem;
font: inherit;
}
.gate a.button, .account button, .admins button {
.gate a.button, .account button, .admins button, .token-banner button {
background: #1a2440;
color: var(--text);
border: 1px solid var(--border);
Expand Down Expand Up @@ -112,6 +112,7 @@ header h1 { margin: 0; font-size: 1.4rem; }
border-radius: 12px;
}
.token-banner p { margin: 0 0 0.6rem; }
.token-banner button { margin-top: 0.6rem; }

.stats {
display: grid;
Expand Down
Loading
Loading