Introduces the web backend (auth, RBAC, project tokens, revisions), YouTrack-or-taskmaster backend choice in the TUI, and a first-pass embedded Svelte UI. The frontend is functional but needs a major rework. Co-authored-by: Cursor <cursoragent@cursor.com>
227 lines
5.9 KiB
Go
227 lines
5.9 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const (
|
|
TokenScopeRead = "read"
|
|
TokenScopeWrite = "write" // includes read
|
|
)
|
|
|
|
// ProjectToken is a project-scoped API credential (secret only on create).
|
|
type ProjectToken struct {
|
|
ID string `json:"id"`
|
|
ProjectID string `json:"projectId"`
|
|
Name string `json:"name"`
|
|
Scope string `json:"scope"`
|
|
CreatedBy string `json:"createdBy,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
RevokedAt *time.Time `json:"revokedAt,omitempty"`
|
|
Token string `json:"token,omitempty"` // raw secret, only set on create
|
|
}
|
|
|
|
// CreateProjectToken issues a new token; raw value is returned once in Token.
|
|
func (db *DB) CreateProjectToken(projectID, name, scope, createdBy string) (ProjectToken, error) {
|
|
name = strings.TrimSpace(name)
|
|
scope = strings.TrimSpace(scope)
|
|
if projectID == "" || name == "" {
|
|
return ProjectToken{}, fmt.Errorf("projectId and name required")
|
|
}
|
|
if scope != TokenScopeRead && scope != TokenScopeWrite {
|
|
return ProjectToken{}, fmt.Errorf("scope must be read or write")
|
|
}
|
|
raw, err := randomToken()
|
|
if err != nil {
|
|
return ProjectToken{}, err
|
|
}
|
|
hash := hashToken(raw)
|
|
t := ProjectToken{
|
|
ID: uuid.NewString(),
|
|
ProjectID: projectID,
|
|
Name: name,
|
|
Scope: scope,
|
|
CreatedBy: createdBy,
|
|
CreatedAt: time.Now().UTC(),
|
|
Token: raw,
|
|
}
|
|
_, err = db.SQL.Exec(`
|
|
INSERT INTO project_tokens(id, project_id, name, token_hash, scope, created_by, created_at)
|
|
VALUES(?,?,?,?,?,?,?)`,
|
|
t.ID, t.ProjectID, t.Name, hash, t.Scope, nullStr(createdBy), formatTime(t.CreatedAt))
|
|
if err != nil {
|
|
return ProjectToken{}, err
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
func randomToken() (string, error) {
|
|
buf := make([]byte, 32)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return "tm_" + base64.RawURLEncoding.EncodeToString(buf), nil
|
|
}
|
|
|
|
func hashToken(raw string) string {
|
|
sum := sha256.Sum256([]byte(raw))
|
|
return base64.RawStdEncoding.EncodeToString(sum[:])
|
|
}
|
|
|
|
func nullStr(s string) any {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return s
|
|
}
|
|
|
|
// ListProjectTokens returns non-secret token metadata for a project.
|
|
func (db *DB) ListProjectTokens(projectID string) ([]ProjectToken, error) {
|
|
rows, err := db.SQL.Query(`
|
|
SELECT id, project_id, name, scope, created_by, created_at, revoked_at
|
|
FROM project_tokens WHERE project_id = ?
|
|
ORDER BY created_at DESC`, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []ProjectToken
|
|
for rows.Next() {
|
|
t, err := scanProjectToken(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
if out == nil {
|
|
out = []ProjectToken{}
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// RevokeProjectToken soft-revokes a token.
|
|
func (db *DB) RevokeProjectToken(projectID, tokenID string) error {
|
|
res, err := db.SQL.Exec(`
|
|
UPDATE project_tokens SET revoked_at = ? WHERE id = ? AND project_id = ? AND revoked_at IS NULL`,
|
|
formatTime(time.Now().UTC()), tokenID, projectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("token not found or already revoked")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TokenAuth is the resolved bearer identity.
|
|
type TokenAuth struct {
|
|
User User
|
|
ProjectID string
|
|
Scope string
|
|
TokenID string
|
|
}
|
|
|
|
// ResolveBearerToken validates a raw API token.
|
|
func (db *DB) ResolveBearerToken(raw string) (TokenAuth, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return TokenAuth{}, fmt.Errorf("empty token")
|
|
}
|
|
hash := hashToken(raw)
|
|
var (
|
|
tokenID, projectID, scope string
|
|
createdBy sql.NullString
|
|
revoked sql.NullString
|
|
)
|
|
err := db.SQL.QueryRow(`
|
|
SELECT id, project_id, scope, created_by, revoked_at
|
|
FROM project_tokens WHERE token_hash = ?`, hash).
|
|
Scan(&tokenID, &projectID, &scope, &createdBy, &revoked)
|
|
if err == sql.ErrNoRows {
|
|
return TokenAuth{}, fmt.Errorf("invalid token")
|
|
}
|
|
if err != nil {
|
|
return TokenAuth{}, err
|
|
}
|
|
if revoked.Valid && revoked.String != "" {
|
|
return TokenAuth{}, fmt.Errorf("token revoked")
|
|
}
|
|
var u User
|
|
if createdBy.Valid && createdBy.String != "" {
|
|
u, err = db.GetUserByID(createdBy.String)
|
|
if err != nil {
|
|
// creator deleted — synthetic limited principal
|
|
u = User{ID: createdBy.String, Username: "token", IsAdmin: false}
|
|
}
|
|
} else {
|
|
u = User{ID: "token:" + tokenID, Username: "token", IsAdmin: false}
|
|
}
|
|
u.PasswordHash = ""
|
|
return TokenAuth{User: u, ProjectID: projectID, Scope: scope, TokenID: tokenID}, nil
|
|
}
|
|
|
|
// TokenAllows reports whether scope permits the operation (write implies read).
|
|
func TokenAllows(scope, need string) bool {
|
|
if need == TokenScopeRead {
|
|
return scope == TokenScopeRead || scope == TokenScopeWrite
|
|
}
|
|
return scope == TokenScopeWrite
|
|
}
|
|
|
|
func scanProjectToken(scanner interface{ Scan(dest ...any) error }) (ProjectToken, error) {
|
|
var t ProjectToken
|
|
var createdBy sql.NullString
|
|
var created, revoked sql.NullString
|
|
err := scanner.Scan(&t.ID, &t.ProjectID, &t.Name, &t.Scope, &createdBy, &created, &revoked)
|
|
if err != nil {
|
|
return ProjectToken{}, err
|
|
}
|
|
if createdBy.Valid {
|
|
t.CreatedBy = createdBy.String
|
|
}
|
|
t.CreatedAt = parseTime(created.String)
|
|
if revoked.Valid && revoked.String != "" {
|
|
tm := parseTime(revoked.String)
|
|
t.RevokedAt = &tm
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
// DeleteUser removes a user and their sessions.
|
|
func (db *DB) DeleteUser(id string) error {
|
|
if id == "" {
|
|
return fmt.Errorf("user id required")
|
|
}
|
|
res, err := db.SQL.Exec(`DELETE FROM users WHERE id = ?`, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("user not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetUserAdmin updates the is_admin flag.
|
|
func (db *DB) SetUserAdmin(id string, isAdmin bool) error {
|
|
res, err := db.SQL.Exec(`UPDATE users SET is_admin = ? WHERE id = ?`, boolInt(isAdmin), id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("user not found")
|
|
}
|
|
return nil
|
|
}
|