Files

192 lines
6.0 KiB
Go

package store
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"linkport/internal/security"
"strings"
"time"
)
type APIToken struct {
ID string `json:"id"`
Name string `json:"name"`
Owner string `json:"owner"`
Scopes []string `json:"scopes"`
ExpiresAt string `json:"expiresAt"`
CreatedAt string `json:"createdAt"`
Revoked bool `json:"revoked"`
Revision string `json:"-"`
}
type ConfigTemplate struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"createdAt"`
Bundle Bundle `json:"bundle"`
}
func (s *Store) initAutomation() error {
_, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS api_tokens(id TEXT PRIMARY KEY,name TEXT NOT NULL,owner TEXT NOT NULL,revision TEXT NOT NULL,scopes TEXT NOT NULL,hash TEXT NOT NULL UNIQUE,expires_at TEXT NOT NULL,created_at TEXT NOT NULL,revoked INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS config_templates(id TEXT PRIMARY KEY,config BLOB NOT NULL);
CREATE TABLE IF NOT EXISTS operational_settings(id TEXT PRIMARY KEY,config BLOB NOT NULL);`)
return err
}
func tokenHash(secret string) string {
sum := sha256.Sum256([]byte(secret))
return hex.EncodeToString(sum[:])
}
func (s *Store) CreateToken(t APIToken) (APIToken, string, error) {
if strings.TrimSpace(t.Name) == "" || len(t.Name) > 100 {
return t, "", errors.New("token name required (max 100 bytes)")
}
expiry, err := time.Parse(time.RFC3339, t.ExpiresAt)
if err != nil || !expiry.After(time.Now()) || expiry.After(time.Now().Add(366*24*time.Hour)) {
return t, "", errors.New("token expiry must be within 366 days")
}
if len(t.Scopes) == 0 || len(t.Scopes) > 4 {
return t, "", errors.New("select token scopes")
}
for _, scope := range t.Scopes {
if scope != "read" && scope != "write" && scope != "audit" && scope != "metrics" {
return t, "", errors.New("invalid token scope")
}
}
rev, err := s.PrimaryRevision(t.Owner)
if err != nil {
return t, "", errors.New("token owner must be the personal login account")
}
t.ID = security.Token()[:16]
t.Revision = rev
t.CreatedAt = time.Now().UTC().Format(time.RFC3339)
t.ExpiresAt = expiry.UTC().Format(time.RFC3339)
secret := "spt_" + security.Token()
scopes, _ := json.Marshal(t.Scopes)
_, err = s.db.Exec("INSERT INTO api_tokens(id,name,owner,revision,scopes,hash,expires_at,created_at) VALUES(?,?,?,?,?,?,?,?)", t.ID, t.Name, t.Owner, rev, string(scopes), tokenHash(secret), t.ExpiresAt, t.CreatedAt)
return t, secret, err
}
func (s *Store) Tokens() ([]APIToken, error) {
rows, err := s.db.Query("SELECT id,name,owner,scopes,expires_at,created_at,revoked FROM api_tokens WHERE owner=(SELECT username FROM admin WHERE id=1) ORDER BY created_at DESC")
if err != nil {
return nil, err
}
defer rows.Close()
out := []APIToken{}
for rows.Next() {
var t APIToken
var scopes string
if err = rows.Scan(&t.ID, &t.Name, &t.Owner, &scopes, &t.ExpiresAt, &t.CreatedAt, &t.Revoked); err != nil {
return nil, err
}
if err = json.Unmarshal([]byte(scopes), &t.Scopes); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
func (s *Store) VerifyToken(secret string) (APIToken, error) {
var t APIToken
var scopes string
if !strings.HasPrefix(secret, "spt_") || len(secret) > 256 {
return t, sql.ErrNoRows
}
err := s.db.QueryRow("SELECT id,name,owner,revision,scopes,expires_at FROM api_tokens WHERE hash=? AND revoked=0", tokenHash(secret)).Scan(&t.ID, &t.Name, &t.Owner, &t.Revision, &scopes, &t.ExpiresAt)
if err != nil {
return t, err
}
expiry, err := time.Parse(time.RFC3339, t.ExpiresAt)
if err != nil || !expiry.After(time.Now()) {
return t, sql.ErrNoRows
}
rev, err := s.PrimaryRevision(t.Owner)
if err != nil || rev != t.Revision {
return t, sql.ErrNoRows
}
err = json.Unmarshal([]byte(scopes), &t.Scopes)
return t, err
}
func (s *Store) RevokeToken(id string) error {
r, err := s.db.Exec("UPDATE api_tokens SET revoked=1 WHERE id=?", id)
if err != nil {
return err
}
n, err := r.RowsAffected()
if err == nil && n == 0 {
return sql.ErrNoRows
}
return err
}
func (s *Store) SaveTemplate(name string, b Bundle) (ConfigTemplate, error) {
if strings.TrimSpace(name) == "" || len(name) > 100 {
return ConfigTemplate{}, errors.New("template name required (max 100 bytes)")
}
if err := validateTemplate(b); err != nil {
return ConfigTemplate{}, err
}
b = RedactBundle(b)
t := ConfigTemplate{ID: security.Token()[:16], Name: name, CreatedAt: time.Now().UTC().Format(time.RFC3339), Bundle: b}
sealed, err := s.vault.Seal(mustJSON(t), "template:"+t.ID)
if err != nil {
return ConfigTemplate{}, err
}
_, err = s.db.Exec("INSERT INTO config_templates(id,config) VALUES(?,?)", t.ID, sealed)
return t, err
}
func mustJSON(v any) []byte { b, _ := json.Marshal(v); return b }
func (s *Store) Templates() ([]ConfigTemplate, error) {
rows, err := s.db.Query("SELECT id,config FROM config_templates ORDER BY id")
if err != nil {
return nil, err
}
defer rows.Close()
out := []ConfigTemplate{}
for rows.Next() {
var id string
var raw []byte
if err = rows.Scan(&id, &raw); err != nil {
return nil, err
}
raw, err = s.vault.Open(raw, "template:"+id)
if err != nil {
return nil, err
}
var t ConfigTemplate
if err = json.Unmarshal(raw, &t); err != nil {
return nil, err
}
t.Bundle = Bundle{}
out = append(out, t)
}
return out, rows.Err()
}
func (s *Store) Template(id string) (ConfigTemplate, error) {
var raw []byte
if err := s.db.QueryRow("SELECT config FROM config_templates WHERE id=?", id).Scan(&raw); err != nil {
return ConfigTemplate{}, err
}
raw, err := s.vault.Open(raw, "template:"+id)
if err != nil {
return ConfigTemplate{}, err
}
var t ConfigTemplate
err = json.Unmarshal(raw, &t)
t.Bundle = RedactBundle(t.Bundle)
return t, err
}
func (s *Store) DeleteTemplate(id string) error {
r, err := s.db.Exec("DELETE FROM config_templates WHERE id=?", id)
if err != nil {
return err
}
n, _ := r.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}