diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c767da2 --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +SQL_SRC_DIR := database +SQL_FILES := $(wildcard $(SQL_SRC_DIR)/{migrations,queries}/*.sql) + +all: sqlc + +sqlc: $(SQL_FILES) + sqlc generate + +build: sqlc + go build ./cmd/lavender diff --git a/database/clientstore.go b/database/clientstore.go index a9a332a..4700693 100644 --- a/database/clientstore.go +++ b/database/clientstore.go @@ -1,6 +1,10 @@ package database -import "github.com/go-oauth2/oauth2/v4" +import ( + "bufio" + "github.com/go-oauth2/oauth2/v4" + "strings" +) var _ oauth2.ClientInfo = &ClientStore{} @@ -8,7 +12,7 @@ func (c *ClientStore) GetID() string { return c.Subject } func (c *ClientStore) GetSecret() string { return c.Secret } func (c *ClientStore) GetDomain() string { return c.Domain } func (c *ClientStore) IsPublic() bool { return c.Public } -func (c *ClientStore) GetUserID() string { return c.Owner } +func (c *ClientStore) GetUserID() string { return c.OwnerSubject } // GetName is an extra field for the oauth handler to display the application // name @@ -22,4 +26,12 @@ func (c *ClientStore) IsSSO() bool { return c.Sso } func (c *ClientStore) IsActive() bool { return c.Active } // UsePerms is an extra field for the userinfo handler to return user permissions matching the requested values -func (c *ClientStore) UsePerms() string { return c.Perms } +func (c *ClientStore) UsePerms() []string { + perms := make([]string, 0) + sc := bufio.NewScanner(strings.NewReader(c.Perms)) + sc.Split(bufio.ScanWords) + if sc.Scan() { + perms = append(perms, sc.Text()) + } + return perms +} diff --git a/database/manage-oauth.sql.go b/database/manage-oauth.sql.go index ebf160d..02d4a56 100644 --- a/database/manage-oauth.sql.go +++ b/database/manage-oauth.sql.go @@ -10,32 +10,39 @@ import ( ) const getAppList = `-- name: GetAppList :many -SELECT subject, name, domain, owner, perms, public, sso, active +SELECT subject, + name, + domain, + owner_subject, + perms, + public, + sso, + active FROM client_store -WHERE owner = ? +WHERE owner_subject = ? OR ? = 1 LIMIT 25 OFFSET ? ` type GetAppListParams struct { - Owner string `json:"owner"` - Column2 interface{} `json:"column_2"` - Offset int64 `json:"offset"` + OwnerSubject string `json:"owner_subject"` + Column2 interface{} `json:"column_2"` + Offset int64 `json:"offset"` } type GetAppListRow struct { - Subject string `json:"subject"` - Name string `json:"name"` - Domain string `json:"domain"` - Owner string `json:"owner"` - Perms string `json:"perms"` - Public bool `json:"public"` - Sso bool `json:"sso"` - Active bool `json:"active"` + Subject string `json:"subject"` + Name string `json:"name"` + Domain string `json:"domain"` + OwnerSubject string `json:"owner_subject"` + Perms string `json:"perms"` + Public bool `json:"public"` + Sso bool `json:"sso"` + Active bool `json:"active"` } func (q *Queries) GetAppList(ctx context.Context, arg GetAppListParams) ([]GetAppListRow, error) { - rows, err := q.db.QueryContext(ctx, getAppList, arg.Owner, arg.Column2, arg.Offset) + rows, err := q.db.QueryContext(ctx, getAppList, arg.OwnerSubject, arg.Column2, arg.Offset) if err != nil { return nil, err } @@ -47,7 +54,7 @@ func (q *Queries) GetAppList(ctx context.Context, arg GetAppListParams) ([]GetAp &i.Subject, &i.Name, &i.Domain, - &i.Owner, + &i.OwnerSubject, &i.Perms, &i.Public, &i.Sso, @@ -67,7 +74,7 @@ func (q *Queries) GetAppList(ctx context.Context, arg GetAppListParams) ([]GetAp } const getClientInfo = `-- name: GetClientInfo :one -SELECT subject, name, secret, domain, owner, perms, public, sso, active +SELECT subject, name, secret, domain, owner_subject, perms, public, sso, active FROM client_store WHERE subject = ? LIMIT 1 @@ -81,7 +88,7 @@ func (q *Queries) GetClientInfo(ctx context.Context, subject string) (ClientStor &i.Name, &i.Secret, &i.Domain, - &i.Owner, + &i.OwnerSubject, &i.Perms, &i.Public, &i.Sso, @@ -91,20 +98,20 @@ func (q *Queries) GetClientInfo(ctx context.Context, subject string) (ClientStor } const insertClientApp = `-- name: InsertClientApp :exec -INSERT INTO client_store (subject, name, secret, domain, owner, perms, public, sso, active) +INSERT INTO client_store (subject, name, secret, domain, perms, public, sso, active, owner_subject) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ` type InsertClientAppParams struct { - Subject string `json:"subject"` - Name string `json:"name"` - Secret string `json:"secret"` - Domain string `json:"domain"` - Owner string `json:"owner"` - Perms string `json:"perms"` - Public bool `json:"public"` - Sso bool `json:"sso"` - Active bool `json:"active"` + Subject string `json:"subject"` + Name string `json:"name"` + Secret string `json:"secret"` + Domain string `json:"domain"` + Perms string `json:"perms"` + Public bool `json:"public"` + Sso bool `json:"sso"` + Active bool `json:"active"` + OwnerSubject string `json:"owner_subject"` } func (q *Queries) InsertClientApp(ctx context.Context, arg InsertClientAppParams) error { @@ -113,11 +120,11 @@ func (q *Queries) InsertClientApp(ctx context.Context, arg InsertClientAppParams arg.Name, arg.Secret, arg.Domain, - arg.Owner, arg.Perms, arg.Public, arg.Sso, arg.Active, + arg.OwnerSubject, ) return err } @@ -126,17 +133,17 @@ const resetClientAppSecret = `-- name: ResetClientAppSecret :exec UPDATE client_store SET secret = ? WHERE subject = ? - AND owner = ? + AND owner_subject = ? ` type ResetClientAppSecretParams struct { - Secret string `json:"secret"` - Subject string `json:"subject"` - Owner string `json:"owner"` + Secret string `json:"secret"` + Subject string `json:"subject"` + OwnerSubject string `json:"owner_subject"` } func (q *Queries) ResetClientAppSecret(ctx context.Context, arg ResetClientAppSecretParams) error { - _, err := q.db.ExecContext(ctx, resetClientAppSecret, arg.Secret, arg.Subject, arg.Owner) + _, err := q.db.ExecContext(ctx, resetClientAppSecret, arg.Secret, arg.Subject, arg.OwnerSubject) return err } @@ -149,19 +156,19 @@ SET name = ?, sso = ?, active = ? WHERE subject = ? - AND owner = ? + AND owner_subject = ? ` type UpdateClientAppParams struct { - Name string `json:"name"` - Domain string `json:"domain"` - Column3 bool `json:"column_3"` - Perms string `json:"perms"` - Public bool `json:"public"` - Sso bool `json:"sso"` - Active bool `json:"active"` - Subject string `json:"subject"` - Owner string `json:"owner"` + Name string `json:"name"` + Domain string `json:"domain"` + Column3 bool `json:"column_3"` + Perms string `json:"perms"` + Public bool `json:"public"` + Sso bool `json:"sso"` + Active bool `json:"active"` + Subject string `json:"subject"` + OwnerSubject string `json:"owner_subject"` } func (q *Queries) UpdateClientApp(ctx context.Context, arg UpdateClientAppParams) error { @@ -174,7 +181,7 @@ func (q *Queries) UpdateClientApp(ctx context.Context, arg UpdateClientAppParams arg.Sso, arg.Active, arg.Subject, - arg.Owner, + arg.OwnerSubject, ) return err } diff --git a/database/manage-users.sql.go b/database/manage-users.sql.go index 94513d0..8b0a99a 100644 --- a/database/manage-users.sql.go +++ b/database/manage-users.sql.go @@ -7,27 +7,51 @@ package database import ( "context" + "strings" "time" ) +const changeUserActive = `-- name: ChangeUserActive :exec +UPDATE users +SET active = cast(? as boolean) +WHERE subject = ? +` + +type ChangeUserActiveParams struct { + Column1 bool `json:"column_1"` + Subject string `json:"subject"` +} + +func (q *Queries) ChangeUserActive(ctx context.Context, arg ChangeUserActiveParams) error { + _, err := q.db.ExecContext(ctx, changeUserActive, arg.Column1, arg.Subject) + return err +} + const getUserList = `-- name: GetUserList :many -SELECT subject, +SELECT users.subject, + name, + picture, + website, email, email_verified, - roles, - updated_at, + users.updated_at as user_updated_at, + p.updated_at as profile_updated_at, active FROM users -LIMIT 25 OFFSET ? + INNER JOIN main.profiles p on users.subject = p.subject +LIMIT 50 OFFSET ? ` type GetUserListRow struct { - Subject string `json:"subject"` - Email string `json:"email"` - EmailVerified bool `json:"email_verified"` - Roles string `json:"roles"` - UpdatedAt time.Time `json:"updated_at"` - Active bool `json:"active"` + Subject string `json:"subject"` + Name string `json:"name"` + Picture string `json:"picture"` + Website string `json:"website"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + UserUpdatedAt time.Time `json:"user_updated_at"` + ProfileUpdatedAt time.Time `json:"profile_updated_at"` + Active bool `json:"active"` } func (q *Queries) GetUserList(ctx context.Context, offset int64) ([]GetUserListRow, error) { @@ -41,10 +65,13 @@ func (q *Queries) GetUserList(ctx context.Context, offset int64) ([]GetUserListR var i GetUserListRow if err := rows.Scan( &i.Subject, + &i.Name, + &i.Picture, + &i.Website, &i.Email, &i.EmailVerified, - &i.Roles, - &i.UpdatedAt, + &i.UserUpdatedAt, + &i.ProfileUpdatedAt, &i.Active, ); err != nil { return nil, err @@ -60,22 +87,50 @@ func (q *Queries) GetUserList(ctx context.Context, offset int64) ([]GetUserListR return items, nil } -const updateUser = `-- name: UpdateUser :exec -UPDATE users -SET active = ?, - roles=? -WHERE subject = ? +const getUsersRoles = `-- name: GetUsersRoles :many +SELECT r.role, u.id +FROM users_roles + INNER JOIN roles r on r.id = users_roles.role_id + INNER JOIN users u on u.id = users_roles.user_id +WHERE u.id in /*SLICE:user_ids*/? ` -type UpdateUserParams struct { - Active bool `json:"active"` - Roles string `json:"roles"` - Subject string `json:"subject"` +type GetUsersRolesRow struct { + Role string `json:"role"` + ID int64 `json:"id"` } -func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error { - _, err := q.db.ExecContext(ctx, updateUser, arg.Active, arg.Roles, arg.Subject) - return err +func (q *Queries) GetUsersRoles(ctx context.Context, userIds []int64) ([]GetUsersRolesRow, error) { + query := getUsersRoles + var queryParams []interface{} + if len(userIds) > 0 { + for _, v := range userIds { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:user_ids*/?", strings.Repeat(",?", len(userIds))[1:], 1) + } else { + query = strings.Replace(query, "/*SLICE:user_ids*/?", "NULL", 1) + } + rows, err := q.db.QueryContext(ctx, query, queryParams...) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetUsersRolesRow + for rows.Next() { + var i GetUsersRolesRow + if err := rows.Scan(&i.Role, &i.ID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil } const userEmailExists = `-- name: UserEmailExists :one diff --git a/database/migrations/20240517171813_init.down.sql b/database/migrations/20240517171813_init.down.sql deleted file mode 100644 index bdfa645..0000000 --- a/database/migrations/20240517171813_init.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP TABLE users; -DROP TABLE client_store; diff --git a/database/migrations/20240517171813_init.up.sql b/database/migrations/20240517171813_init.up.sql deleted file mode 100644 index 67b0724..0000000 --- a/database/migrations/20240517171813_init.up.sql +++ /dev/null @@ -1,27 +0,0 @@ -CREATE TABLE users -( - subject TEXT PRIMARY KEY UNIQUE NOT NULL, - email TEXT UNIQUE NOT NULL, - email_verified BOOLEAN DEFAULT 0 NOT NULL, - roles TEXT NOT NULL, - userinfo TEXT NOT NULL, - access_token TEXT, - refresh_token TEXT, - expiry DATETIME, - updated_at DATETIME NOT NULL, - active BOOLEAN DEFAULT 1 NOT NULL -); - -CREATE TABLE client_store -( - subject TEXT PRIMARY KEY UNIQUE NOT NULL, - name TEXT NOT NULL, - secret TEXT UNIQUE NOT NULL, - domain TEXT NOT NULL, - owner TEXT NOT NULL, - perms TEXT NOT NULL, - public BOOLEAN NOT NULL, - sso BOOLEAN NOT NULL, - active BOOLEAN DEFAULT 1 NOT NULL, - FOREIGN KEY (owner) REFERENCES users (subject) -); diff --git a/database/migrations/20240820202502_init.down.sql b/database/migrations/20240820202502_init.down.sql new file mode 100644 index 0000000..406e2c3 --- /dev/null +++ b/database/migrations/20240820202502_init.down.sql @@ -0,0 +1,5 @@ +DROP TABLE users; +DROP INDEX username_index; +DROP TABLE roles; +DROP TABLE otp; +DROP TABLE client_store; diff --git a/database/migrations/20240820202502_init.up.sql b/database/migrations/20240820202502_init.up.sql new file mode 100644 index 0000000..27795f0 --- /dev/null +++ b/database/migrations/20240820202502_init.up.sql @@ -0,0 +1,69 @@ +CREATE TABLE users +( + id INTEGER NOT NULL UNIQUE PRIMARY KEY AUTOINCREMENT, + subject TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + + email TEXT NOT NULL, + email_verified BOOLEAN NOT NULL DEFAULT 0, + + updated_at DATETIME NOT NULL, + registered DATETIME NOT NULL, + active BOOLEAN NOT NULL DEFAULT 1 +); + +CREATE INDEX users_subject ON users (subject); + +CREATE TABLE profiles +( + subject TEXT NOT NULL UNIQUE PRIMARY KEY, + name TEXT NOT NULL, + picture TEXT NOT NULL DEFAULT '', + website TEXT NOT NULL DEFAULT '', + pronouns TEXT NOT NULL DEFAULT 'they/them', + birthdate DATE NULL, + zone TEXT NOT NULL DEFAULT 'UTC', + locale TEXT NOT NULL DEFAULT 'en-US', + updated_at DATETIME NOT NULL +); + +CREATE TABLE roles +( + id INTEGER NOT NULL UNIQUE PRIMARY KEY AUTOINCREMENT, + role TEXT NOT NULL UNIQUE +); + +CREATE TABLE users_roles +( + role_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + + FOREIGN KEY (role_id) REFERENCES roles (id), + FOREIGN KEY (user_id) REFERENCES users (id), + + CONSTRAINT user_role UNIQUE (role_id, user_id) +); + +CREATE TABLE otp +( + subject INTEGER NOT NULL UNIQUE PRIMARY KEY, + secret TEXT NOT NULL, + digits INTEGER NOT NULL, + + FOREIGN KEY (subject) REFERENCES users (subject) +); + +CREATE TABLE client_store +( + subject TEXT NOT NULL UNIQUE PRIMARY KEY, + name TEXT NOT NULL, + secret TEXT NOT NULL UNIQUE, + domain TEXT NOT NULL, + owner_subject TEXT NOT NULL, + perms TEXT NOT NULL, + public BOOLEAN NOT NULL, + sso BOOLEAN NOT NULL, + active BOOLEAN NOT NULL DEFAULT 1, + + FOREIGN KEY (owner_subject) REFERENCES users (subject) +); diff --git a/database/migrations/20240820202502_merge-auth.down.sql b/database/migrations/20240820202502_merge-auth.down.sql deleted file mode 100644 index e69de29..0000000 diff --git a/database/migrations/20240820202502_merge-auth.up.sql b/database/migrations/20240820202502_merge-auth.up.sql deleted file mode 100644 index e69de29..0000000 diff --git a/database/models.go b/database/models.go index 41316ab..fc0b48f 100644 --- a/database/models.go +++ b/database/models.go @@ -5,31 +5,58 @@ package database import ( - "database/sql" "time" + + "github.com/1f349/lavender/password" ) type ClientStore struct { - Subject string `json:"subject"` - Name string `json:"name"` + Subject string `json:"subject"` + Name string `json:"name"` + Secret string `json:"secret"` + Domain string `json:"domain"` + OwnerSubject string `json:"owner_subject"` + Perms string `json:"perms"` + Public bool `json:"public"` + Sso bool `json:"sso"` + Active bool `json:"active"` +} + +type Otp struct { + Subject int64 `json:"subject"` Secret string `json:"secret"` - Domain string `json:"domain"` - Owner string `json:"owner"` - Perms string `json:"perms"` - Public bool `json:"public"` - Sso bool `json:"sso"` - Active bool `json:"active"` + Digits int64 `json:"digits"` +} + +type Profile struct { + Subject string `json:"subject"` + Name string `json:"name"` + Picture string `json:"picture"` + Website string `json:"website"` + Pronouns string `json:"pronouns"` + Birthdate interface{} `json:"birthdate"` + Zone string `json:"zone"` + Locale string `json:"locale"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Role struct { + ID int64 `json:"id"` + Role string `json:"role"` } type User struct { - Subject string `json:"subject"` - Email string `json:"email"` - EmailVerified bool `json:"email_verified"` - Roles string `json:"roles"` - Userinfo string `json:"userinfo"` - AccessToken sql.NullString `json:"access_token"` - RefreshToken sql.NullString `json:"refresh_token"` - Expiry sql.NullTime `json:"expiry"` - UpdatedAt time.Time `json:"updated_at"` - Active bool `json:"active"` + ID int64 `json:"id"` + Subject string `json:"subject"` + Password password.HashString `json:"password"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + UpdatedAt time.Time `json:"updated_at"` + Registered time.Time `json:"registered"` + Active bool `json:"active"` +} + +type UsersRole struct { + RoleID int64 `json:"role_id"` + UserID int64 `json:"user_id"` } diff --git a/database/otp.sql.go b/database/otp.sql.go new file mode 100644 index 0000000..fc30726 --- /dev/null +++ b/database/otp.sql.go @@ -0,0 +1,81 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.25.0 +// source: otp.sql + +package database + +import ( + "context" +) + +const deleteOtp = `-- name: DeleteOtp :exec +DELETE +FROM otp +WHERE otp.subject = ? +` + +func (q *Queries) DeleteOtp(ctx context.Context, subject int64) error { + _, err := q.db.ExecContext(ctx, deleteOtp, subject) + return err +} + +const getOtp = `-- name: GetOtp :one +SELECT secret, digits +FROM otp +WHERE subject = ? +` + +type GetOtpRow struct { + Secret string `json:"secret"` + Digits int64 `json:"digits"` +} + +func (q *Queries) GetOtp(ctx context.Context, subject int64) (GetOtpRow, error) { + row := q.db.QueryRowContext(ctx, getOtp, subject) + var i GetOtpRow + err := row.Scan(&i.Secret, &i.Digits) + return i, err +} + +const getUserEmail = `-- name: GetUserEmail :one +SELECT email +FROM users +WHERE subject = ? +` + +func (q *Queries) GetUserEmail(ctx context.Context, subject string) (string, error) { + row := q.db.QueryRowContext(ctx, getUserEmail, subject) + var email string + err := row.Scan(&email) + return email, err +} + +const hasOtp = `-- name: HasOtp :one +SELECT EXISTS(SELECT 1 FROM otp WHERE subject = ?) == 1 as hasOtp +` + +func (q *Queries) HasOtp(ctx context.Context, subject int64) (bool, error) { + row := q.db.QueryRowContext(ctx, hasOtp, subject) + var hasotp bool + err := row.Scan(&hasotp) + return hasotp, err +} + +const setOtp = `-- name: SetOtp :exec +INSERT OR +REPLACE +INTO otp (subject, secret, digits) +VALUES (?, ?, ?) +` + +type SetOtpParams struct { + Subject int64 `json:"subject"` + Secret string `json:"secret"` + Digits int64 `json:"digits"` +} + +func (q *Queries) SetOtp(ctx context.Context, arg SetOtpParams) error { + _, err := q.db.ExecContext(ctx, setOtp, arg.Subject, arg.Secret, arg.Digits) + return err +} diff --git a/database/password-wrapper.go b/database/password-wrapper.go new file mode 100644 index 0000000..e7a0d48 --- /dev/null +++ b/database/password-wrapper.go @@ -0,0 +1,78 @@ +package database + +import ( + "context" + "github.com/1f349/lavender/database/types" + "github.com/1f349/lavender/password" + "github.com/google/uuid" + "time" +) + +type AddUserParams struct { + Name string `json:"name"` + Username string `json:"username"` + Password string `json:"password"` + Email string `json:"email"` + Role types.UserRole `json:"role"` + UpdatedAt time.Time `json:"updated_at"` + Active bool `json:"active"` +} + +func (q *Queries) AddUser(ctx context.Context, arg AddUserParams) (string, error) { + pwHash, err := password.HashPassword(arg.Password) + if err != nil { + return "", err + } + n := time.Now() + a := addUserParams{ + Subject: uuid.NewString(), + Password: pwHash, + Email: arg.Email, + EmailVerified: false, + UpdatedAt: n, + Registered: n, + Active: true, + } + return a.Subject, q.addUser(ctx, a) +} + +type CheckLoginResult struct { + Subject string `json:"subject"` + HasOtp bool `json:"has_otp"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` +} + +func (q *Queries) CheckLogin(ctx context.Context, un, pw string) (CheckLoginResult, error) { + login, err := q.checkLogin(ctx, un) + if err != nil { + return CheckLoginResult{}, err + } + err = password.CheckPasswordHash(login.Password, pw) + if err != nil { + return CheckLoginResult{}, err + } + return CheckLoginResult{ + Subject: login.Subject, + HasOtp: login.HasOtp, + Email: login.Email, + EmailVerified: login.EmailVerified, + }, nil +} + +func (q *Queries) ChangePassword(ctx context.Context, subject, newPw string) error { + userPassword, err := q.getUserPassword(ctx, subject) + if err != nil { + return err + } + newPwHash, err := password.HashPassword(newPw) + if err != nil { + return err + } + return q.changeUserPassword(ctx, changeUserPasswordParams{ + Password: newPwHash, + UpdatedAt: time.Now(), + Subject: subject, + Password_2: userPassword, + }) +} diff --git a/database/profile-patch.go b/database/profile-patch.go new file mode 100644 index 0000000..74f2125 --- /dev/null +++ b/database/profile-patch.go @@ -0,0 +1,62 @@ +package database + +import ( + "fmt" + "github.com/1f349/lavender/database/types" + "github.com/hardfinhq/go-date" + "github.com/mrmelon54/pronouns" + "golang.org/x/text/language" + "net/url" + "time" +) + +type ProfilePatch struct { + Name string `json:"name"` + Picture string `json:"picture"` + Website string `json:"website"` + Pronouns types.UserPronoun `json:"pronouns"` + Birthdate date.NullDate `json:"birthdate"` + Zone types.UserZone `json:"zone"` + Locale types.UserLocale `json:"locale"` +} + +func (p *ProfilePatch) ParseFromForm(v url.Values) (safeErrs []error) { + var err error + p.Name = v.Get("name") + p.Picture = v.Get("picture") + p.Website = v.Get("website") + if v.Has("reset_pronouns") { + p.Pronouns.Pronoun = pronouns.TheyThem + } else { + p.Pronouns.Pronoun, err = pronouns.FindPronoun(v.Get("pronouns")) + if err != nil { + safeErrs = append(safeErrs, fmt.Errorf("invalid pronoun selected")) + } + } + if v.Has("reset_birthdate") || v.Get("birthdate") == "" { + p.Birthdate = date.NullDate{} + } else { + p.Birthdate = date.NullDate{Valid: true} + p.Birthdate.Date, err = date.FromString(v.Get("birthdate")) + if err != nil { + safeErrs = append(safeErrs, fmt.Errorf("invalid time selected")) + } + } + if v.Has("reset_zoneinfo") { + p.Zone.Location = time.UTC + } else { + p.Zone.Location, err = time.LoadLocation(v.Get("zoneinfo")) + if err != nil { + safeErrs = append(safeErrs, fmt.Errorf("invalid timezone selected")) + } + } + if v.Has("reset_locale") { + p.Locale.Tag = language.AmericanEnglish + } else { + p.Locale.Tag, err = language.Parse(v.Get("locale")) + if err != nil { + safeErrs = append(safeErrs, fmt.Errorf("invalid language selected")) + } + } + return +} diff --git a/database/profiles.sql.go b/database/profiles.sql.go new file mode 100644 index 0000000..fd54f5c --- /dev/null +++ b/database/profiles.sql.go @@ -0,0 +1,74 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.25.0 +// source: profiles.sql + +package database + +import ( + "context" + "time" +) + +const getProfile = `-- name: GetProfile :one +SELECT profiles.subject, profiles.name, profiles.picture, profiles.website, profiles.pronouns, profiles.birthdate, profiles.zone, profiles.locale, profiles.updated_at +FROM profiles +WHERE subject = ? +` + +func (q *Queries) GetProfile(ctx context.Context, subject string) (Profile, error) { + row := q.db.QueryRowContext(ctx, getProfile, subject) + var i Profile + err := row.Scan( + &i.Subject, + &i.Name, + &i.Picture, + &i.Website, + &i.Pronouns, + &i.Birthdate, + &i.Zone, + &i.Locale, + &i.UpdatedAt, + ) + return i, err +} + +const modifyProfile = `-- name: ModifyProfile :exec +UPDATE profiles +SET name = ?, + picture = ?, + website = ?, + pronouns = ?, + birthdate = ?, + zone = ?, + locale = ?, + updated_at = ? +WHERE subject = ? +` + +type ModifyProfileParams struct { + Name string `json:"name"` + Picture string `json:"picture"` + Website string `json:"website"` + Pronouns string `json:"pronouns"` + Birthdate interface{} `json:"birthdate"` + Zone string `json:"zone"` + Locale string `json:"locale"` + UpdatedAt time.Time `json:"updated_at"` + Subject string `json:"subject"` +} + +func (q *Queries) ModifyProfile(ctx context.Context, arg ModifyProfileParams) error { + _, err := q.db.ExecContext(ctx, modifyProfile, + arg.Name, + arg.Picture, + arg.Website, + arg.Pronouns, + arg.Birthdate, + arg.Zone, + arg.Locale, + arg.UpdatedAt, + arg.Subject, + ) + return err +} diff --git a/database/queries/manage-oauth.sql b/database/queries/manage-oauth.sql index 28082aa..7225f40 100644 --- a/database/queries/manage-oauth.sql +++ b/database/queries/manage-oauth.sql @@ -5,14 +5,21 @@ WHERE subject = ? LIMIT 1; -- name: GetAppList :many -SELECT subject, name, domain, owner, perms, public, sso, active +SELECT subject, + name, + domain, + owner_subject, + perms, + public, + sso, + active FROM client_store -WHERE owner = ? +WHERE owner_subject = ? OR ? = 1 LIMIT 25 OFFSET ?; -- name: InsertClientApp :exec -INSERT INTO client_store (subject, name, secret, domain, owner, perms, public, sso, active) +INSERT INTO client_store (subject, name, secret, domain, perms, public, sso, active, owner_subject) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); -- name: UpdateClientApp :exec @@ -24,10 +31,10 @@ SET name = ?, sso = ?, active = ? WHERE subject = ? - AND owner = ?; + AND owner_subject = ?; -- name: ResetClientAppSecret :exec UPDATE client_store SET secret = ? WHERE subject = ? - AND owner = ?; + AND owner_subject = ?; diff --git a/database/queries/manage-users.sql b/database/queries/manage-users.sql index 344ca4c..587b87e 100644 --- a/database/queries/manage-users.sql +++ b/database/queries/manage-users.sql @@ -1,17 +1,27 @@ -- name: GetUserList :many -SELECT subject, +SELECT users.subject, + name, + picture, + website, email, email_verified, - roles, - updated_at, + users.updated_at as user_updated_at, + p.updated_at as profile_updated_at, active FROM users -LIMIT 25 OFFSET ?; + INNER JOIN main.profiles p on users.subject = p.subject +LIMIT 50 OFFSET ?; --- name: UpdateUser :exec +-- name: GetUsersRoles :many +SELECT r.role, u.id +FROM users_roles + INNER JOIN roles r on r.id = users_roles.role_id + INNER JOIN users u on u.id = users_roles.user_id +WHERE u.id in sqlc.slice(user_ids); + +-- name: ChangeUserActive :exec UPDATE users -SET active = ?, - roles=? +SET active = cast(? as boolean) WHERE subject = ?; -- name: UserEmailExists :one diff --git a/database/queries/otp.sql b/database/queries/otp.sql new file mode 100644 index 0000000..175399a --- /dev/null +++ b/database/queries/otp.sql @@ -0,0 +1,23 @@ +-- name: SetOtp :exec +INSERT OR +REPLACE +INTO otp (subject, secret, digits) +VALUES (?, ?, ?); + +-- name: DeleteOtp :exec +DELETE +FROM otp +WHERE otp.subject = ?; + +-- name: GetOtp :one +SELECT secret, digits +FROM otp +WHERE subject = ?; + +-- name: HasOtp :one +SELECT EXISTS(SELECT 1 FROM otp WHERE subject = ?) == 1 as hasOtp; + +-- name: GetUserEmail :one +SELECT email +FROM users +WHERE subject = ?; diff --git a/database/queries/profiles.sql b/database/queries/profiles.sql new file mode 100644 index 0000000..134da89 --- /dev/null +++ b/database/queries/profiles.sql @@ -0,0 +1,16 @@ +-- name: GetProfile :one +SELECT profiles.* +FROM profiles +WHERE subject = ?; + +-- name: ModifyProfile :exec +UPDATE profiles +SET name = ?, + picture = ?, + website = ?, + pronouns = ?, + birthdate = ?, + zone = ?, + locale = ?, + updated_at = ? +WHERE subject = ?; diff --git a/database/queries/users.sql b/database/queries/users.sql index e565f83..d41fcce 100644 --- a/database/queries/users.sql +++ b/database/queries/users.sql @@ -2,21 +2,15 @@ SELECT count(subject) > 0 AS hasUser FROM users; --- name: AddUser :exec -INSERT INTO users (subject, email, email_verified, roles, userinfo, updated_at, active) +-- name: addUser :exec +INSERT INTO users (subject, password, email, email_verified, updated_at, registered, active) VALUES (?, ?, ?, ?, ?, ?, ?); --- name: UpdateUserInfo :exec -UPDATE users -SET email = ?, - email_verified = ?, - userinfo = ? -WHERE subject = ?; - --- name: GetUserRoles :one -SELECT roles +-- name: checkLogin :one +SELECT subject, password, EXISTS(SELECT 1 FROM otp WHERE otp.subject = users.subject) == 1 AS has_otp, email, email_verified FROM users -WHERE subject = ?; +WHERE users.subject = ? +LIMIT 1; -- name: GetUser :one SELECT * @@ -24,20 +18,29 @@ FROM users WHERE subject = ? LIMIT 1; --- name: UpdateUserToken :exec +-- name: GetUserRoles :many +SELECT r.role +FROM users_roles + INNER JOIN roles r on r.id = users_roles.role_id + INNER JOIN users u on u.id = users_roles.user_id +WHERE u.subject = ?; + +-- name: UserHasRole :one +SELECT 1 +FROM roles + INNER JOIN users_roles on users_roles.user_id = roles.id + INNER JOIN users u on u.id = users_roles.user_id = u.id +WHERE roles.role = ? + AND u.subject = ?; + +-- name: getUserPassword :one +SELECT password +FROM users +WHERE subject = ?; + +-- name: changeUserPassword :exec UPDATE users -SET access_token = ?, - refresh_token = ?, - expiry = ? -WHERE subject = ?; - --- name: GetUserToken :one -SELECT access_token, refresh_token, expiry -FROM users +SET password = ?, + updated_at=? WHERE subject = ? -LIMIT 1; - --- name: GetUserEmail :one -SELECT email -FROM users -WHERE subject = ?; + AND password = ?; diff --git a/database/users.sql.go b/database/users.sql.go index c3044f0..c814a26 100644 --- a/database/users.sql.go +++ b/database/users.sql.go @@ -7,40 +7,13 @@ package database import ( "context" - "database/sql" "time" + + "github.com/1f349/lavender/password" ) -const addUser = `-- name: AddUser :exec -INSERT INTO users (subject, email, email_verified, roles, userinfo, updated_at, active) -VALUES (?, ?, ?, ?, ?, ?, ?) -` - -type AddUserParams struct { - Subject string `json:"subject"` - Email string `json:"email"` - EmailVerified bool `json:"email_verified"` - Roles string `json:"roles"` - Userinfo string `json:"userinfo"` - UpdatedAt time.Time `json:"updated_at"` - Active bool `json:"active"` -} - -func (q *Queries) AddUser(ctx context.Context, arg AddUserParams) error { - _, err := q.db.ExecContext(ctx, addUser, - arg.Subject, - arg.Email, - arg.EmailVerified, - arg.Roles, - arg.Userinfo, - arg.UpdatedAt, - arg.Active, - ) - return err -} - const getUser = `-- name: GetUser :one -SELECT subject, email, email_verified, roles, userinfo, access_token, refresh_token, expiry, updated_at, active +SELECT id, subject, password, email, email_verified, updated_at, registered, active FROM users WHERE subject = ? LIMIT 1 @@ -50,64 +23,47 @@ func (q *Queries) GetUser(ctx context.Context, subject string) (User, error) { row := q.db.QueryRowContext(ctx, getUser, subject) var i User err := row.Scan( + &i.ID, &i.Subject, + &i.Password, &i.Email, &i.EmailVerified, - &i.Roles, - &i.Userinfo, - &i.AccessToken, - &i.RefreshToken, - &i.Expiry, &i.UpdatedAt, + &i.Registered, &i.Active, ) return i, err } -const getUserEmail = `-- name: GetUserEmail :one -SELECT email -FROM users -WHERE subject = ? +const getUserRoles = `-- name: GetUserRoles :many +SELECT r.role +FROM users_roles + INNER JOIN roles r on r.id = users_roles.role_id + INNER JOIN users u on u.id = users_roles.user_id +WHERE u.subject = ? ` -func (q *Queries) GetUserEmail(ctx context.Context, subject string) (string, error) { - row := q.db.QueryRowContext(ctx, getUserEmail, subject) - var email string - err := row.Scan(&email) - return email, err -} - -const getUserRoles = `-- name: GetUserRoles :one -SELECT roles -FROM users -WHERE subject = ? -` - -func (q *Queries) GetUserRoles(ctx context.Context, subject string) (string, error) { - row := q.db.QueryRowContext(ctx, getUserRoles, subject) - var roles string - err := row.Scan(&roles) - return roles, err -} - -const getUserToken = `-- name: GetUserToken :one -SELECT access_token, refresh_token, expiry -FROM users -WHERE subject = ? -LIMIT 1 -` - -type GetUserTokenRow struct { - AccessToken sql.NullString `json:"access_token"` - RefreshToken sql.NullString `json:"refresh_token"` - Expiry sql.NullTime `json:"expiry"` -} - -func (q *Queries) GetUserToken(ctx context.Context, subject string) (GetUserTokenRow, error) { - row := q.db.QueryRowContext(ctx, getUserToken, subject) - var i GetUserTokenRow - err := row.Scan(&i.AccessToken, &i.RefreshToken, &i.Expiry) - return i, err +func (q *Queries) GetUserRoles(ctx context.Context, subject string) ([]string, error) { + rows, err := q.db.QueryContext(ctx, getUserRoles, subject) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var role string + if err := rows.Scan(&role); err != nil { + return nil, err + } + items = append(items, role) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil } const hasUser = `-- name: HasUser :one @@ -122,52 +78,117 @@ func (q *Queries) HasUser(ctx context.Context) (bool, error) { return hasuser, err } -const updateUserInfo = `-- name: UpdateUserInfo :exec -UPDATE users -SET email = ?, - email_verified = ?, - userinfo = ? -WHERE subject = ? +const userHasRole = `-- name: UserHasRole :one +SELECT 1 +FROM roles + INNER JOIN users_roles on users_roles.user_id = roles.id + INNER JOIN users u on u.id = users_roles.user_id = u.id +WHERE roles.role = ? + AND u.subject = ? ` -type UpdateUserInfoParams struct { - Email string `json:"email"` - EmailVerified bool `json:"email_verified"` - Userinfo string `json:"userinfo"` - Subject string `json:"subject"` +type UserHasRoleParams struct { + Role string `json:"role"` + Subject string `json:"subject"` } -func (q *Queries) UpdateUserInfo(ctx context.Context, arg UpdateUserInfoParams) error { - _, err := q.db.ExecContext(ctx, updateUserInfo, +func (q *Queries) UserHasRole(ctx context.Context, arg UserHasRoleParams) (int64, error) { + row := q.db.QueryRowContext(ctx, userHasRole, arg.Role, arg.Subject) + var column_1 int64 + err := row.Scan(&column_1) + return column_1, err +} + +const addUser = `-- name: addUser :exec +INSERT INTO users (subject, password, email, email_verified, updated_at, registered, active) +VALUES (?, ?, ?, ?, ?, ?, ?) +` + +type addUserParams struct { + Subject string `json:"subject"` + Password password.HashString `json:"password"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + UpdatedAt time.Time `json:"updated_at"` + Registered time.Time `json:"registered"` + Active bool `json:"active"` +} + +func (q *Queries) addUser(ctx context.Context, arg addUserParams) error { + _, err := q.db.ExecContext(ctx, addUser, + arg.Subject, + arg.Password, arg.Email, arg.EmailVerified, - arg.Userinfo, - arg.Subject, + arg.UpdatedAt, + arg.Registered, + arg.Active, ) return err } -const updateUserToken = `-- name: UpdateUserToken :exec +const changeUserPassword = `-- name: changeUserPassword :exec UPDATE users -SET access_token = ?, - refresh_token = ?, - expiry = ? +SET password = ?, + updated_at=? +WHERE subject = ? + AND password = ? +` + +type changeUserPasswordParams struct { + Password password.HashString `json:"password"` + UpdatedAt time.Time `json:"updated_at"` + Subject string `json:"subject"` + Password_2 password.HashString `json:"password_2"` +} + +func (q *Queries) changeUserPassword(ctx context.Context, arg changeUserPasswordParams) error { + _, err := q.db.ExecContext(ctx, changeUserPassword, + arg.Password, + arg.UpdatedAt, + arg.Subject, + arg.Password_2, + ) + return err +} + +const checkLogin = `-- name: checkLogin :one +SELECT subject, password, EXISTS(SELECT 1 FROM otp WHERE otp.subject = users.subject) == 1 AS has_otp, email, email_verified +FROM users +WHERE users.subject = ? +LIMIT 1 +` + +type checkLoginRow struct { + Subject string `json:"subject"` + Password password.HashString `json:"password"` + HasOtp bool `json:"has_otp"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` +} + +func (q *Queries) checkLogin(ctx context.Context, subject string) (checkLoginRow, error) { + row := q.db.QueryRowContext(ctx, checkLogin, subject) + var i checkLoginRow + err := row.Scan( + &i.Subject, + &i.Password, + &i.HasOtp, + &i.Email, + &i.EmailVerified, + ) + return i, err +} + +const getUserPassword = `-- name: getUserPassword :one +SELECT password +FROM users WHERE subject = ? ` -type UpdateUserTokenParams struct { - AccessToken sql.NullString `json:"access_token"` - RefreshToken sql.NullString `json:"refresh_token"` - Expiry sql.NullTime `json:"expiry"` - Subject string `json:"subject"` -} - -func (q *Queries) UpdateUserToken(ctx context.Context, arg UpdateUserTokenParams) error { - _, err := q.db.ExecContext(ctx, updateUserToken, - arg.AccessToken, - arg.RefreshToken, - arg.Expiry, - arg.Subject, - ) - return err +func (q *Queries) getUserPassword(ctx context.Context, subject string) (password.HashString, error) { + row := q.db.QueryRowContext(ctx, getUserPassword, subject) + var password password.HashString + err := row.Scan(&password) + return password, err } diff --git a/go.mod b/go.mod index efb0d14..dc2b926 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/golang-migrate/migrate/v4 v4.17.1 github.com/google/subcommands v1.2.0 github.com/google/uuid v1.6.0 + github.com/hardfinhq/go-date v1.20240411.1 github.com/julienschmidt/httprouter v1.3.0 github.com/mattn/go-sqlite3 v1.14.22 github.com/mrmelon54/pronouns v1.0.3 diff --git a/go.sum b/go.sum index a362a74..98374f7 100644 --- a/go.sum +++ b/go.sum @@ -83,6 +83,8 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORR github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hardfinhq/go-date v1.20240411.1 h1:UskRXxgD+4eCEa8CpiiWLiv2/Vnf1eB90Bojkf7AQ64= +github.com/hardfinhq/go-date v1.20240411.1/go.mod h1:7oxaI9XX4W3/MRDeQXec0fLXFnSJDS7BrazIY2XqPXA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= diff --git a/issuer/manager_test.go b/issuer/manager_test.go index 143014e..8f316f3 100644 --- a/issuer/manager_test.go +++ b/issuer/manager_test.go @@ -26,10 +26,9 @@ func TestManager_CheckNamespace(t *testing.T) { httpGet = func(url string) (resp *http.Response, err error) { return &http.Response{StatusCode: http.StatusOK, Body: testBody()}, nil } - manager, err := NewManager([]SsoConfig{ - { - Addr: testAddrUrl, - Namespace: "example.com", + manager, err := NewManager(map[string]SsoConfig{ + "example.com": { + Addr: testAddrUrl, }, }) assert.NoError(t, err) @@ -41,10 +40,9 @@ func TestManager_FindServiceFromLogin(t *testing.T) { httpGet = func(url string) (resp *http.Response, err error) { return &http.Response{StatusCode: http.StatusOK, Body: testBody()}, nil } - manager, err := NewManager([]SsoConfig{ - { - Addr: testAddrUrl, - Namespace: "example.com", + manager, err := NewManager(map[string]SsoConfig{ + "example.com": { + Addr: testAddrUrl, }, }) assert.NoError(t, err) diff --git a/issuer/sso.go b/issuer/sso.go index 4db6feb..ee81aec 100644 --- a/issuer/sso.go +++ b/issuer/sso.go @@ -62,6 +62,7 @@ func (s SsoConfig) FetchConfig() (*WellKnownOIDC, error) { } type WellKnownOIDC struct { + Namespace string `json:"-"` Config SsoConfig `json:"-"` Issuer string `json:"issuer"` AuthorizationEndpoint string `json:"authorization_endpoint"` diff --git a/server/auth.go b/server/auth.go index 31c0a73..2e7d741 100644 --- a/server/auth.go +++ b/server/auth.go @@ -23,7 +23,7 @@ var ErrAuthHttpError = errors.New("auth http error") func (h *HttpServer) RequireAdminAuthentication(next UserHandler) httprouter.Handle { return h.RequireAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { - var roles string + var roles []string if h.DbTx(rw, func(tx *database.Queries) (err error) { roles, err = tx.GetUserRoles(req.Context(), auth.Subject) return diff --git a/server/home.go b/server/home.go index 0142967..75571cb 100644 --- a/server/home.go +++ b/server/home.go @@ -30,9 +30,9 @@ func (h *HttpServer) Home(rw http.ResponseWriter, req *http.Request, _ httproute var isAdmin bool h.DbTx(rw, func(tx *database.Queries) (err error) { - roles, err := tx.GetUserRoles(req.Context(), auth.Subject) - isAdmin = HasRole(roles, "lavender:admin") - return err + _, err = tx.UserHasRole(req.Context(), database.UserHasRoleParams{Role: "lavender:admin", Subject: auth.Subject}) + isAdmin = err == nil + return nil }) pages.RenderPageTemplate(rw, "index", map[string]any{ diff --git a/server/jwt.go b/server/jwt.go index 31f14bc..6d23725 100644 --- a/server/jwt.go +++ b/server/jwt.go @@ -30,9 +30,12 @@ func (j *JWTAccessGenerate) Token(ctx context.Context, data *oauth2.GenerateBasi return "", "", err } - ps := auth.ParsePermStorage(roles) + ps := auth.NewPermStorage() + for _, role := range roles { + ps.Set(role) + } out := auth.NewPermStorage() - ForEachRole(data.Client.(interface{ UsePerms() string }).UsePerms(), func(role string) { + ForEachRole(data.Client.(interface{ UsePerms() []string }).UsePerms(), func(role string) { for _, i := range ps.Filter(strings.Split(role, " ")).Dump() { out.Set(i) } diff --git a/server/roles.go b/server/roles.go index 0d9af20..b3dcfea 100644 --- a/server/roles.go +++ b/server/roles.go @@ -1,25 +1,16 @@ package server -import ( - "bufio" - "strings" -) - -func HasRole(roles, test string) bool { - sc := bufio.NewScanner(strings.NewReader(roles)) - sc.Split(bufio.ScanWords) - for sc.Scan() { - if sc.Text() == test { +func HasRole(roles []string, test string) bool { + for _, role := range roles { + if role == test { return true } } return false } -func ForEachRole(roles string, next func(role string)) { - sc := bufio.NewScanner(strings.NewReader(roles)) - sc.Split(bufio.ScanWords) - for sc.Scan() { - next(sc.Text()) +func ForEachRole(roles []string, next func(role string)) { + for _, role := range roles { + next(role) } } diff --git a/sqlc.yaml b/sqlc.yaml index 5ace449..2716e86 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -10,14 +10,14 @@ sql: emit_json_tags: true overrides: - column: "users.password" - go_type: "github.com/1f349/tulip/password.HashString" + go_type: "github.com/1f349/lavender/password.HashString" - column: "users.birthdate" go_type: "github.com/hardfinhq/go-date.NullDate" - column: "users.role" - go_type: "github.com/1f349/tulip/database/types.UserRole" + go_type: "github.com/1f349/lavender/database/types.UserRole" - column: "users.pronouns" - go_type: "github.com/1f349/tulip/database/types.UserPronoun" + go_type: "github.com/1f349/lavender/database/types.UserPronoun" - column: "users.zoneinfo" - go_type: "github.com/1f349/tulip/database/types.UserZone" + go_type: "github.com/1f349/lavender/database/types.UserZone" - column: "users.locale" - go_type: "github.com/1f349/tulip/database/types.UserLocale" + go_type: "github.com/1f349/lavender/database/types.UserLocale"