initial pase store definitions
This commit is contained in:
parent
fefe08f6f9
commit
b947535795
10 changed files with 723 additions and 0 deletions
5
go.mod
Normal file
5
go.mod
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
module git.juancwu.dev/juancwu/pase
|
||||||
|
|
||||||
|
go 1.26.2
|
||||||
|
|
||||||
|
require github.com/oklog/ulid/v2 v2.1.1 // indirect
|
||||||
3
go.sum
Normal file
3
go.sum
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
|
||||||
|
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
||||||
|
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||||
70
store/dialect.go
Normal file
70
store/dialect.go
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dialect captures the small syntactic differences between SQL backends.
|
||||||
|
// All queries are written with ? placeholders; Rebind rewrites them as
|
||||||
|
// needed for the target driver. Semantic differences (e.g., RETURNING
|
||||||
|
// support) are not the Dialect's concern — they belong in separate Store
|
||||||
|
// implementations.
|
||||||
|
type Dialect interface {
|
||||||
|
Rebind(query string) string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQLiteDialect leaves queries unchanged; database/sql + the SQLite driver
|
||||||
|
// already accept ? placeholders.
|
||||||
|
type SQLiteDialect struct{}
|
||||||
|
|
||||||
|
func (SQLiteDialect) Rebind(query string) string { return query }
|
||||||
|
|
||||||
|
// PostgresDialect rewrites ? placeholders into $1, $2, ... while leaving
|
||||||
|
// any ? characters that appear inside single-quoted string literals or
|
||||||
|
// double-quoted identifiers untouched.
|
||||||
|
type PostgresDialect struct{}
|
||||||
|
|
||||||
|
func (PostgresDialect) Rebind(query string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(query) + 8)
|
||||||
|
|
||||||
|
n := 0
|
||||||
|
i := 0
|
||||||
|
for i < len(query) {
|
||||||
|
c := query[i]
|
||||||
|
switch c {
|
||||||
|
case '\'', '"':
|
||||||
|
// Copy the entire quoted span verbatim, handling doubled-quote escapes.
|
||||||
|
quote := c
|
||||||
|
b.WriteByte(c)
|
||||||
|
i++
|
||||||
|
for i < len(query) {
|
||||||
|
if query[i] == quote {
|
||||||
|
if i+1 < len(query) && query[i+1] == quote {
|
||||||
|
// Escaped quote: '' or "". Copy both bytes and continue.
|
||||||
|
b.WriteByte(quote)
|
||||||
|
b.WriteByte(quote)
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteByte(quote)
|
||||||
|
i++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
b.WriteByte(query[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
case '?':
|
||||||
|
n++
|
||||||
|
b.WriteByte('$')
|
||||||
|
b.WriteString(strconv.Itoa(n))
|
||||||
|
i++
|
||||||
|
default:
|
||||||
|
b.WriteByte(c)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
68
store/dialect_test.go
Normal file
68
store/dialect_test.go
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSQLiteDialect_Rebind_passthrough(t *testing.T) {
|
||||||
|
d := SQLiteDialect{}
|
||||||
|
in := `SELECT * FROM pase_users WHERE email = ? AND status = ?`
|
||||||
|
if got := d.Rebind(in); got != in {
|
||||||
|
t.Errorf("SQLite Rebind should be passthrough.\nin: %s\ngot: %s", in, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresDialect_Rebind(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no placeholders",
|
||||||
|
in: `SELECT 1`,
|
||||||
|
want: `SELECT 1`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single placeholder",
|
||||||
|
in: `SELECT * FROM pase_users WHERE id = ?`,
|
||||||
|
want: `SELECT * FROM pase_users WHERE id = $1`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple placeholders",
|
||||||
|
in: `INSERT INTO pase_users (id, email, status) VALUES (?, ?, ?)`,
|
||||||
|
want: `INSERT INTO pase_users (id, email, status) VALUES ($1, $2, $3)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "question mark inside single-quoted literal is preserved",
|
||||||
|
in: `SELECT * FROM t WHERE name = 'who?' AND id = ?`,
|
||||||
|
want: `SELECT * FROM t WHERE name = 'who?' AND id = $1`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "escaped single quote inside literal",
|
||||||
|
in: `SELECT * FROM t WHERE name = 'O''Reilly?' AND id = ?`,
|
||||||
|
want: `SELECT * FROM t WHERE name = 'O''Reilly?' AND id = $1`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "question mark inside double-quoted identifier is preserved",
|
||||||
|
in: `SELECT "weird?col" FROM t WHERE id = ?`,
|
||||||
|
want: `SELECT "weird?col" FROM t WHERE id = $1`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "escaped double quote inside identifier",
|
||||||
|
in: `SELECT "a""?b" FROM t WHERE id = ?`,
|
||||||
|
want: `SELECT "a""?b" FROM t WHERE id = $1`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mix of literals and placeholders",
|
||||||
|
in: `UPDATE t SET name = 'foo?', other = ? WHERE id = ? AND tag = 'x?'`,
|
||||||
|
want: `UPDATE t SET name = 'foo?', other = $1 WHERE id = $2 AND tag = 'x?'`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
d := PostgresDialect{}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := d.Rebind(tc.in); got != tc.want {
|
||||||
|
t.Errorf("Rebind mismatch\nin: %s\nwant: %s\ngot: %s", tc.in, tc.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
54
store/jsonb.go
Normal file
54
store/jsonb.go
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JSONB is a nullable JSON value backed by raw bytes.
|
||||||
|
// Scans from jsonb (Postgres) or TEXT (SQLite). Empty means SQL NULL.
|
||||||
|
type JSONB []byte
|
||||||
|
|
||||||
|
// Scan implements sql.Scanner.
|
||||||
|
func (j *JSONB) Scan(src any) error {
|
||||||
|
if src == nil {
|
||||||
|
*j = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch v := src.(type) {
|
||||||
|
case []byte:
|
||||||
|
// Copy: drivers may reuse the buffer between rows.
|
||||||
|
*j = append((*j)[:0], v...)
|
||||||
|
case string:
|
||||||
|
*j = []byte(v)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("pase: cannot scan %T into JSONB", src)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value implements driver.Valuer.
|
||||||
|
func (j JSONB) Value() (driver.Value, error) {
|
||||||
|
if len(j) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return []byte(j), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON makes JSONB transparent in API responses.
|
||||||
|
func (j JSONB) MarshalJSON() ([]byte, error) {
|
||||||
|
if len(j) == 0 {
|
||||||
|
return []byte("null"), nil
|
||||||
|
}
|
||||||
|
return []byte(j), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON lets you decode directly into JSONB.
|
||||||
|
func (j *JSONB) UnmarshalJSON(data []byte) error {
|
||||||
|
if string(data) == "null" {
|
||||||
|
*j = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
*j = append((*j)[:0], data...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
157
store/migrate.go
Normal file
157
store/migrate.go
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Migration struct {
|
||||||
|
Version int
|
||||||
|
Name string
|
||||||
|
SQL string
|
||||||
|
}
|
||||||
|
|
||||||
|
var migrationFilenameRE = regexp.MustCompile(`^(\d+)_([a-z0-9_]+)\.sql$`)
|
||||||
|
|
||||||
|
// LoadMigrations reads SQL files from an embedded FS.
|
||||||
|
// Files must be named like "001_initial.sql".
|
||||||
|
func LoadMigrations(fsys embed.FS, dir string) ([]Migration, error) {
|
||||||
|
entries, err := fs.ReadDir(fsys, dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read migration dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var migrations []Migration
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := migrationFilenameRE.FindStringSubmatch(e.Name())
|
||||||
|
if m == nil {
|
||||||
|
return nil, fmt.Errorf("invalid migration filename: %s", e.Name())
|
||||||
|
}
|
||||||
|
version, err := strconv.Atoi(m[1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid version in %s: %w", e.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := fs.ReadFile(fsys, dir+"/"+e.Name())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read %s: %w", e.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
migrations = append(migrations, Migration{
|
||||||
|
Version: version,
|
||||||
|
Name: m[2],
|
||||||
|
SQL: string(content),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(migrations, func(i, j int) bool {
|
||||||
|
return migrations[i].Version < migrations[j].Version
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sanity check: versions must be unique and contiguous starting at 1.
|
||||||
|
for i, m := range migrations {
|
||||||
|
if m.Version != i+1 {
|
||||||
|
return nil, fmt.Errorf("migration version gap: expected %d, got %d (%s)",
|
||||||
|
i+1, m.Version, m.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return migrations, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrator applies migrations to a database.
|
||||||
|
type Migrator struct {
|
||||||
|
DB *sql.DB
|
||||||
|
Dialect Dialect
|
||||||
|
Migrations []Migration
|
||||||
|
|
||||||
|
// CreateTableSQL is the dialect-specific DDL for the schema_migrations
|
||||||
|
// table. Postgres and SQLite need slightly different syntax.
|
||||||
|
CreateTableSQL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate applies all pending migrations.
|
||||||
|
func (m *Migrator) Migrate(ctx context.Context) error {
|
||||||
|
if _, err := m.DB.ExecContext(ctx, m.CreateTableSQL); err != nil {
|
||||||
|
return fmt.Errorf("create schema_migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
applied, err := m.appliedVersions(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mig := range m.Migrations {
|
||||||
|
if applied[mig.Version] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := m.apply(ctx, mig); err != nil {
|
||||||
|
return fmt.Errorf("apply migration %d (%s): %w", mig.Version, mig.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Migrator) appliedVersions(ctx context.Context) (map[int]bool, error) {
|
||||||
|
rows, err := m.DB.QueryContext(ctx, `SELECT version FROM pase_schema_migrations`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read schema_migrations: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
applied := make(map[int]bool)
|
||||||
|
for rows.Next() {
|
||||||
|
var v int
|
||||||
|
if err := rows.Scan(&v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
applied[v] = true
|
||||||
|
}
|
||||||
|
return applied, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Migrator) apply(ctx context.Context, mig Migration) error {
|
||||||
|
tx, err := m.DB.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// Allow multi-statement migrations.
|
||||||
|
for _, stmt := range splitStatements(mig.SQL) {
|
||||||
|
if strings.TrimSpace(stmt) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, stmt); err != nil {
|
||||||
|
return fmt.Errorf("exec statement: %w\n--SQL--\n%s", err, stmt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.ExecContext(ctx,
|
||||||
|
m.Dialect.Rebind(`INSERT INTO pase_schema_migrations (version, name, applied_at)
|
||||||
|
VALUES (?, ?, ?)`),
|
||||||
|
mig.Version, mig.Name, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("record migration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitStatements is a naive splitter on semicolons. It works for our
|
||||||
|
// migrations because we control them. Don't use this on user-supplied SQL.
|
||||||
|
func splitStatements(sql string) []string {
|
||||||
|
return strings.Split(sql, ";")
|
||||||
|
}
|
||||||
245
store/models.go
Normal file
245
store/models.go
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusActive UserStatus = "active"
|
||||||
|
StatusDeactivated UserStatus = "deactivated"
|
||||||
|
StatusLocked UserStatus = "locked"
|
||||||
|
StatusBanned UserStatus = "banned"
|
||||||
|
StatusPendingDeletion UserStatus = "pending_deletion"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID string
|
||||||
|
Email string
|
||||||
|
EmailVerifiedAt NullTime
|
||||||
|
Username string
|
||||||
|
UsernameNormalized string
|
||||||
|
DisplayName string
|
||||||
|
ProfileImageURL string
|
||||||
|
|
||||||
|
Status UserStatus
|
||||||
|
StatusReason string
|
||||||
|
StatusChangedAt NullTime
|
||||||
|
StatusExpiresAt NullTime
|
||||||
|
|
||||||
|
FailedLoginCount int
|
||||||
|
LastFailedLoginAt NullTime
|
||||||
|
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Permission struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Description NullString
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Role struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Description NullString
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type RolePermission struct {
|
||||||
|
RoleID string
|
||||||
|
PermissionID string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type PermissionEffect string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PermissionAllow PermissionEffect = "allow"
|
||||||
|
PermissionDeny PermissionEffect = "deny"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserPermission struct {
|
||||||
|
UserID string
|
||||||
|
PermissionID string
|
||||||
|
Effect PermissionEffect
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
IDHash string
|
||||||
|
UserID string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
LastUsedAt time.Time
|
||||||
|
UserAgent NullString
|
||||||
|
IPAddress NullString
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenPurpose string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TokenPurposeMagicLink TokenPurpose = "magic_link"
|
||||||
|
TokenPurposePasswordReset TokenPurpose = "password_reset"
|
||||||
|
TokenPurposeEmailVerify TokenPurpose = "email_verify"
|
||||||
|
TokenPurposeEmailChange TokenPurpose = "email_change"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Token struct {
|
||||||
|
ID string
|
||||||
|
UserID string
|
||||||
|
Purpose TokenPurpose
|
||||||
|
HashedValue string
|
||||||
|
Payload JSONB
|
||||||
|
ExpiresAt time.Time
|
||||||
|
ConsumedAt time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type CredentialType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CredentialPassword CredentialType = "password"
|
||||||
|
CredentialPasskey CredentialType = "passkey"
|
||||||
|
CredentialTOTP CredentialType = "totp"
|
||||||
|
CredentialOAuth CredentialType = "oauth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Credential struct {
|
||||||
|
ID string
|
||||||
|
UserID string
|
||||||
|
Type CredentialType
|
||||||
|
|
||||||
|
// Used by passkeys (credential ID) and OAuth (provider account id).
|
||||||
|
// Null for password and TOTP.
|
||||||
|
Identifier NullString
|
||||||
|
|
||||||
|
// Used by OAuth: "google", "github", etc. Null otherwise.
|
||||||
|
Provider NullString
|
||||||
|
|
||||||
|
// The actual secret material. Format depends on type:
|
||||||
|
// password: argon2id hash string
|
||||||
|
// passkey: COSE public key (base64)
|
||||||
|
// totp: encrypted shared secret
|
||||||
|
// oauth: null (tokens go in `data`)
|
||||||
|
Secret NullString
|
||||||
|
|
||||||
|
// Type-specific fields that don't fit elsewhere:
|
||||||
|
// passkey: { sign_count, transports, aaguid, backup_eligible }
|
||||||
|
// totp: { algorithm, digits, period }
|
||||||
|
// oauth: { access_token, refresh_token, expires_at, scope }
|
||||||
|
Data JSONB
|
||||||
|
|
||||||
|
// Human-friendly label, useful for UI ("My iPhone", "YubiKey 5C").
|
||||||
|
// Especially valuable for passkeys where users have multiple.
|
||||||
|
Name NullString
|
||||||
|
|
||||||
|
LastUsedAt time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type PasskeyData struct {
|
||||||
|
SignCount uint32 `json:"sign_count"`
|
||||||
|
Transports []string `json:"transports"`
|
||||||
|
AAGUID string `json:"aaguid"`
|
||||||
|
BackupEligible bool `json:"backup_eligible"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OAuthData struct {
|
||||||
|
AccessToken string `json:"access_token,omitempty"`
|
||||||
|
RefreshToken string `json:"refresh_token,omitempty"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TOTPData struct {
|
||||||
|
Algorithm string `json:"algorithm"`
|
||||||
|
Digits int `json:"digits"`
|
||||||
|
Period int `json:"period"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Credential) PasskeyData() (*PasskeyData, error) {
|
||||||
|
if c.Type != CredentialPasskey {
|
||||||
|
return nil, fmt.Errorf("pase: credential is %s, not passkey", c.Type)
|
||||||
|
}
|
||||||
|
if len(c.Data) == 0 {
|
||||||
|
return &PasskeyData{}, nil
|
||||||
|
}
|
||||||
|
var d PasskeyData
|
||||||
|
if err := json.Unmarshal(c.Data, &d); err != nil {
|
||||||
|
return nil, fmt.Errorf("pase: decode passkey data: %w", err)
|
||||||
|
}
|
||||||
|
return &d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Credential) SetPasskeyData(d *PasskeyData) error {
|
||||||
|
if c.Type != CredentialPasskey {
|
||||||
|
return fmt.Errorf("pase: credential is %s, not passkey", c.Type)
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(d)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("pase: %w", err)
|
||||||
|
}
|
||||||
|
c.Data = b
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Credential) OAuthData() (*OAuthData, error) {
|
||||||
|
if c.Type != CredentialOAuth {
|
||||||
|
return nil, fmt.Errorf("pase: credential is %s, not oauth", c.Type)
|
||||||
|
}
|
||||||
|
if len(c.Data) == 0 {
|
||||||
|
return &OAuthData{}, nil
|
||||||
|
}
|
||||||
|
var d OAuthData
|
||||||
|
if err := json.Unmarshal(c.Data, &d); err != nil {
|
||||||
|
return nil, fmt.Errorf("pase: decode oauth data: %w", err)
|
||||||
|
}
|
||||||
|
return &d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Credential) SetOAuthData(d *OAuthData) error {
|
||||||
|
if c.Type != CredentialOAuth {
|
||||||
|
return fmt.Errorf("pase: credential is %s, not oauth", c.Type)
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(d)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("pase: %w", err)
|
||||||
|
}
|
||||||
|
c.Data = b
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Credential) TOTPData() (*TOTPData, error) {
|
||||||
|
if c.Type != CredentialTOTP {
|
||||||
|
return nil, fmt.Errorf("pase: credential is %s, not totp", c.Type)
|
||||||
|
}
|
||||||
|
if len(c.Data) == 0 {
|
||||||
|
return &TOTPData{}, nil
|
||||||
|
}
|
||||||
|
var d TOTPData
|
||||||
|
if err := json.Unmarshal(c.Data, &d); err != nil {
|
||||||
|
return nil, fmt.Errorf("pase: decode totp data: %w", err)
|
||||||
|
}
|
||||||
|
return &d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Credential) SetTOTPData(d *TOTPData) error {
|
||||||
|
if c.Type != CredentialTOTP {
|
||||||
|
return fmt.Errorf("pase: credential is %s, not totp", c.Type)
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(d)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("pase: %w", err)
|
||||||
|
}
|
||||||
|
c.Data = b
|
||||||
|
return nil
|
||||||
|
}
|
||||||
48
store/nullables.go
Normal file
48
store/nullables.go
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NullTime sql.NullTime
|
||||||
|
|
||||||
|
func (nt NullTime) MarshalJSON() ([]byte, error) {
|
||||||
|
if nt.Valid {
|
||||||
|
return json.Marshal(nt.Time)
|
||||||
|
}
|
||||||
|
return []byte("null"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (nt *NullTime) UnmarshalJSON(data []byte) error {
|
||||||
|
if string(data) == "null" || string(data) == `""` {
|
||||||
|
nt.Valid = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &nt.Time); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
nt.Valid = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type NullString sql.NullString
|
||||||
|
|
||||||
|
func (ns NullString) MarshalJSON() ([]byte, error) {
|
||||||
|
if ns.Valid {
|
||||||
|
return json.Marshal(ns.String)
|
||||||
|
}
|
||||||
|
return []byte("null"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ns *NullString) UnmarshalJSON(data []byte) error {
|
||||||
|
if string(data) == "null" || string(data) == `""` {
|
||||||
|
ns.Valid = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &ns.String); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ns.Valid = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
52
store/store.go
Normal file
52
store/store.go
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store interface {
|
||||||
|
CreateUser(ctx context.Context, u *User) error
|
||||||
|
GetUserByID(ctx context.Context, id string) (*User, error)
|
||||||
|
GetUserByEmail(ctx context.Context, email string) (*User, error)
|
||||||
|
GetUserByUsername(ctx context.Context, username string) (*User, error)
|
||||||
|
UpdateUser(ctx context.Context, u *User) error
|
||||||
|
|
||||||
|
UpsertCredential(ctx context.Context, c *Credential) error
|
||||||
|
GetCredential(ctx context.Context, userID string, t CredentialType) (*Credential, error)
|
||||||
|
DeleteCredential(ctx context.Context, userID string, t CredentialType) error
|
||||||
|
|
||||||
|
CreateSession(ctx context.Context, s *Session) error
|
||||||
|
GetSession(ctx context.Context, id string) (*Session, error)
|
||||||
|
DeleteSession(ctx context.Context, id string) error
|
||||||
|
DeleteUserSessions(ctx context.Context, userID string) error
|
||||||
|
|
||||||
|
CreateToken(ctx context.Context, t *Token) error
|
||||||
|
ConsumeToken(ctx context.Context, hashedValue string, purpose TokenPurpose) (*Token, error)
|
||||||
|
DeleteExpiredTokens(ctx context.Context, before time.Time) error
|
||||||
|
|
||||||
|
CreatePermission(ctx context.Context, p *Permission) error
|
||||||
|
GetPermissionByName(ctx context.Context, name string) (*Permission, error)
|
||||||
|
ListPermissions(ctx context.Context) ([]*Permission, error)
|
||||||
|
DeletePermission(ctx context.Context, id string) error
|
||||||
|
|
||||||
|
CreateRole(ctx context.Context, r *Role) error
|
||||||
|
GetRoleByName(ctx context.Context, name string) (*Role, error)
|
||||||
|
ListRoles(ctx context.Context) ([]*Role, error)
|
||||||
|
DeleteRole(ctx context.Context, id string) error
|
||||||
|
|
||||||
|
AssignPermissionToRole(ctx context.Context, roleID, permissionID string) error
|
||||||
|
RevokePermissionFromRole(ctx context.Context, roleID, permissionID string) error
|
||||||
|
ListRolePermissions(ctx context.Context, roleID string) ([]*Permission, error)
|
||||||
|
|
||||||
|
AssignRoleToUser(ctx context.Context, userID, roleID string) error
|
||||||
|
RevokeRoleFromUser(ctx context.Context, userID, roleID string) error
|
||||||
|
ListUserRoles(ctx context.Context, userID string) ([]*Role, error)
|
||||||
|
|
||||||
|
SetUserPermission(ctx context.Context, userID, permissionID string, effect PermissionEffect) error
|
||||||
|
DeleteUserPermission(ctx context.Context, userID, permissionID string) error
|
||||||
|
ListUserPermissions(ctx context.Context, userID string) ([]*UserPermission, error)
|
||||||
|
|
||||||
|
UserHasPermissionViaRole(ctx context.Context, userID, permissionName string) (bool, error)
|
||||||
|
ListEffectivePermissions(ctx context.Context, userID string) ([]*Permission, error)
|
||||||
|
}
|
||||||
21
store/ulid.go
Normal file
21
store/ulid.go
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import "github.com/oklog/ulid/v2"
|
||||||
|
|
||||||
|
// IDGenerator produces unique identifiers for entities.
|
||||||
|
type IDGenerator interface {
|
||||||
|
// NewID generates a new unique identifier for entities.
|
||||||
|
NewID() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultIDGenerator generates ULIDs using crypto/rand entropy.
|
||||||
|
type defaultIDGenerator struct{}
|
||||||
|
|
||||||
|
func (defaultIDGenerator) NewID() string {
|
||||||
|
return ulid.Make().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultIDGenerator returns the standard ULID-based generator.
|
||||||
|
func DefaultIDGenerator() IDGenerator {
|
||||||
|
return defaultIDGenerator{}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue