189 lines
5.8 KiB
Go
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, ", ")
|
|
}
|