192 lines
6.0 KiB
Go
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
|
|
}
|