Files

189 lines
5.8 KiB
Go

package web
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/andydixon/relata/internal/api"
"github.com/andydixon/relata/internal/auth"
"github.com/andydixon/relata/internal/domain"
"github.com/andydixon/relata/internal/observability"
)
// tokenRow is one token as shown on the page.
type tokenRow struct {
Token *domain.APIToken
Status string
Expired bool
Revoked bool
Scopes string
}
// scopeChoice is one selectable scope on the creation form.
type scopeChoice struct {
Scope domain.TokenScope
Label string
Description string
Available bool
}
// TokensPage lists the caller's API tokens and offers the creation form.
func (h *Handlers) TokensPage(w http.ResponseWriter, r *http.Request) {
p := api.PrincipalFrom(r.Context())
if p == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
h.renderTokensPage(w, r, p, "", "", http.StatusOK)
}
// renderTokensPage renders the token list and, after creation, the one-time
// secret directly in the POST response. A bearer credential must never travel
// through a redirect URL, where it would enter browser history and proxy logs.
func (h *Handlers) renderTokensPage(w http.ResponseWriter, r *http.Request, p *domain.Principal,
newSecret, newName string, status int) {
toks, err := h.d.Tokens.List(r.Context(), p)
if err != nil {
if newSecret == "" {
h.renderError(w, r, http.StatusInternalServerError, "Could not list tokens",
"The token list could not be read. Please try again.")
return
}
// Creation has already succeeded and the secret is unrecoverable. Show it
// even if the follow-up list refresh failed rather than losing it forever.
toks = nil
}
now := time.Now().UTC()
rows := make([]tokenRow, 0, len(toks))
for _, t := range toks {
row := tokenRow{Token: t, Status: "Active", Scopes: joinScopes(t.Scopes)}
switch {
case t.RevokedAt != nil:
row.Status, row.Revoked = "Revoked", true
case !t.ExpiresAt.IsZero() && !now.Before(t.ExpiresAt):
row.Status, row.Expired = "Expired", true
}
rows = append(rows, row)
}
data := struct {
base
Tokens []tokenRow
Scopes []scopeChoice
// NewSecret is set only immediately after creation.
NewSecret string
NewName string
DefaultTTL int
MaxTTL int
}{
base: h.newBase(r, "API tokens", "settings"),
Tokens: rows,
Scopes: scopeChoicesFor(p.Role),
NewSecret: newSecret,
NewName: newName,
DefaultTTL: int(h.d.Cfg.Auth.OAuth.TokenDefaultTTL.Hours() / 24),
MaxTTL: int(h.d.Cfg.Auth.OAuth.TokenMaxTTL.Hours() / 24),
}
if err != nil {
data.Error = "The token was created, but the token list could not be refreshed. Copy the new token now."
}
h.d.Renderer.Render(w, r, "tokens.html", status, data)
}
// CreateToken issues a token from the interface.
func (h *Handlers) CreateToken(w http.ResponseWriter, r *http.Request) {
p := api.PrincipalFrom(r.Context())
if p == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/settings/tokens?warn=token_invalid", http.StatusSeeOther)
return
}
scopes := make([]domain.TokenScope, 0, 3)
for _, raw := range r.PostForm["scopes"] {
sc := domain.TokenScope(strings.TrimSpace(raw))
if sc.Valid() {
scopes = append(scopes, sc)
}
}
days, _ := strconv.Atoi(r.PostFormValue("ttl_days"))
ttl := time.Duration(days) * 24 * time.Hour
issued, err := h.d.Tokens.Create(r.Context(), p, auth.CreateRequest{
Name: r.PostFormValue("name"),
Scopes: scopes,
TTL: ttl,
})
if err != nil {
observability.LoggerFrom(r.Context()).Warn("token creation refused", "error", err.Error())
http.Redirect(w, r, "/settings/tokens?warn=token_invalid", http.StatusSeeOther)
return
}
h.d.Audit.Record(r.Context(), p, domain.AuditTokenCreated, "api_token", issued.Token.ID, "success",
map[string]string{"name": issued.Token.Name, "prefix": issued.Token.Prefix})
// Render the one-time secret in this response. Redirect URLs must never carry
// bearer credentials: URLs are retained by browsers, proxies and access logs.
h.renderTokensPage(w, r, p, issued.Secret, issued.Token.Name, http.StatusCreated)
}
// RevokeToken makes a token unusable from the interface.
func (h *Handlers) RevokeToken(w http.ResponseWriter, r *http.Request) {
p := api.PrincipalFrom(r.Context())
if p == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
id := r.PathValue("id")
if err := h.d.Tokens.Revoke(r.Context(), p, id); err != nil {
http.Redirect(w, r, "/settings/tokens?warn=token_invalid", http.StatusSeeOther)
return
}
h.d.Audit.Record(r.Context(), p, domain.AuditTokenRevoked, "api_token", id, "success", nil)
http.Redirect(w, r, "/settings/tokens?ok=token_revoked", http.StatusSeeOther)
}
// scopeChoicesFor describes the scopes, marking those the role cannot grant.
//
// An unavailable scope is shown and disabled rather than hidden, so a viewer
// can see that ingestion exists and why it is not offered to them.
func scopeChoicesFor(role domain.Role) []scopeChoice {
return []scopeChoice{
{
Scope: domain.ScopeQuery, Label: "Query",
Description: "Search, generate answers, and read stored items, entities and relationships.",
Available: domain.RoleGrants(role, domain.ScopeQuery),
},
{
Scope: domain.ScopeIngest, Label: "Ingest",
Description: "Add items, upload files, reprocess and delete.",
Available: domain.RoleGrants(role, domain.ScopeIngest),
},
{
Scope: domain.ScopeReview, Label: "Review",
Description: "Confirm, reject and retype relationships; confirm entity aliases.",
Available: domain.RoleGrants(role, domain.ScopeReview),
},
}
}
func joinScopes(scopes []domain.TokenScope) string {
if len(scopes) == 0 {
return "none"
}
parts := make([]string, 0, len(scopes))
for _, s := range scopes {
parts = append(parts, string(s))
}
return strings.Join(parts, ", ")
}