rbac system first stage

This commit is contained in:
2026-02-03 21:37:06 +11:00
parent 24bbc5337b
commit d2b1a252ea
38 changed files with 1966 additions and 114 deletions

143
internal/db/auditlog.go Normal file
View File

@@ -0,0 +1,143 @@
package db
import (
"context"
"database/sql"
"encoding/json"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
type AuditLog struct {
bun.BaseModel `bun:"table:audit_log,alias:al"`
ID int `bun:"id,pk,autoincrement"`
UserID int `bun:"user_id,notnull"`
Action string `bun:"action,notnull"`
ResourceType string `bun:"resource_type,notnull"`
ResourceID *string `bun:"resource_id"`
Details json.RawMessage `bun:"details,type:jsonb"`
IPAddress string `bun:"ip_address"`
UserAgent string `bun:"user_agent"`
Result string `bun:"result,notnull"` // success, denied, error
ErrorMessage *string `bun:"error_message"`
CreatedAt int64 `bun:"created_at,notnull"`
// Relations
User *User `bun:"rel:belongs-to,join:user_id=id"`
}
// TODO: add AuditLogs to match list style with PageOpts
// CreateAuditLog creates a new audit log entry
func CreateAuditLog(ctx context.Context, tx bun.Tx, log *AuditLog) error {
if log == nil {
return errors.New("log cannot be nil")
}
_, err := tx.NewInsert().
Model(log).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "tx.NewInsert")
}
return nil
}
type AuditLogFilters struct {
UserID *int
Action *string
ResourceType *string
Result *string
}
// GetAuditLogs retrieves audit logs with optional filters and pagination
// TODO: change this to use db.PageOpts
func GetAuditLogs(ctx context.Context, tx bun.Tx, limit, offset int, filters *AuditLogFilters) ([]*AuditLog, int, error) {
query := tx.NewSelect().
Model((*AuditLog)(nil)).
Relation("User").
Order("created_at DESC")
// Apply filters if provided
if filters != nil {
if filters.UserID != nil {
query = query.Where("al.user_id = ?", *filters.UserID)
}
if filters.Action != nil {
query = query.Where("al.action = ?", *filters.Action)
}
if filters.ResourceType != nil {
query = query.Where("al.resource_type = ?", *filters.ResourceType)
}
if filters.Result != nil {
query = query.Where("al.result = ?", *filters.Result)
}
}
// Get total count
total, err := query.Count(ctx)
if err != nil {
return nil, 0, errors.Wrap(err, "query.Count")
}
// Get paginated results
var logs []*AuditLog
err = query.
Limit(limit).
Offset(offset).
Scan(ctx, &logs)
if err != nil && err != sql.ErrNoRows {
return nil, 0, errors.Wrap(err, "query.Scan")
}
return logs, total, nil
}
// GetAuditLogsByUser retrieves audit logs for a specific user
// TODO: change this to use db.PageOpts
func GetAuditLogsByUser(ctx context.Context, tx bun.Tx, userID int, limit, offset int) ([]*AuditLog, int, error) {
if userID <= 0 {
return nil, 0, errors.New("userID must be positive")
}
filters := &AuditLogFilters{
UserID: &userID,
}
return GetAuditLogs(ctx, tx, limit, offset, filters)
}
// GetAuditLogsByAction retrieves audit logs for a specific action
// TODO: change this to use db.PageOpts
func GetAuditLogsByAction(ctx context.Context, tx bun.Tx, action string, limit, offset int) ([]*AuditLog, int, error) {
if action == "" {
return nil, 0, errors.New("action cannot be empty")
}
filters := &AuditLogFilters{
Action: &action,
}
return GetAuditLogs(ctx, tx, limit, offset, filters)
}
// CleanupOldAuditLogs deletes audit logs older than the specified timestamp
func CleanupOldAuditLogs(ctx context.Context, tx bun.Tx, olderThan int64) (int, error) {
result, err := tx.NewDelete().
Model((*AuditLog)(nil)).
Where("created_at < ?", olderThan).
Exec(ctx)
if err != nil {
return 0, errors.Wrap(err, "tx.NewDelete")
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return 0, errors.Wrap(err, "result.RowsAffected")
}
return int(rowsAffected), nil
}

View File

@@ -22,14 +22,14 @@ type DiscordToken struct {
// UpdateDiscordToken adds the provided discord token to the database.
// If the user already has a token stored, it will replace that token instead.
func (user *User) UpdateDiscordToken(ctx context.Context, tx bun.Tx, token *discord.Token) error {
func (u *User) UpdateDiscordToken(ctx context.Context, tx bun.Tx, token *discord.Token) error {
if token == nil {
return errors.New("token cannot be nil")
}
expiresAt := time.Now().Add(time.Duration(token.ExpiresIn) * time.Second).Unix()
discordToken := &DiscordToken{
DiscordID: user.DiscordID,
DiscordID: u.DiscordID,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
ExpiresAt: expiresAt,
@@ -44,7 +44,6 @@ func (user *User) UpdateDiscordToken(ctx context.Context, tx bun.Tx, token *disc
Set("refresh_token = EXCLUDED.refresh_token").
Set("expires_at = EXCLUDED.expires_at").
Exec(ctx)
if err != nil {
return errors.Wrap(err, "tx.NewInsert")
}
@@ -53,14 +52,14 @@ func (user *User) UpdateDiscordToken(ctx context.Context, tx bun.Tx, token *disc
// DeleteDiscordTokens deletes a users discord OAuth tokens from the database.
// It returns the DiscordToken so that it can be revoked via the discord API
func (user *User) DeleteDiscordTokens(ctx context.Context, tx bun.Tx) (*DiscordToken, error) {
token, err := user.GetDiscordToken(ctx, tx)
func (u *User) DeleteDiscordTokens(ctx context.Context, tx bun.Tx) (*DiscordToken, error) {
token, err := u.GetDiscordToken(ctx, tx)
if err != nil {
return nil, errors.Wrap(err, "user.GetDiscordToken")
}
_, err = tx.NewDelete().
Model((*DiscordToken)(nil)).
Where("discord_id = ?", user.DiscordID).
Where("discord_id = ?", u.DiscordID).
Exec(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewDelete")
@@ -69,11 +68,11 @@ func (user *User) DeleteDiscordTokens(ctx context.Context, tx bun.Tx) (*DiscordT
}
// GetDiscordToken retrieves the users discord token from the database
func (user *User) GetDiscordToken(ctx context.Context, tx bun.Tx) (*DiscordToken, error) {
func (u *User) GetDiscordToken(ctx context.Context, tx bun.Tx) (*DiscordToken, error) {
token := new(DiscordToken)
err := tx.NewSelect().
Model(token).
Where("discord_id = ?", user.DiscordID).
Where("discord_id = ?", u.DiscordID).
Limit(1).
Scan(ctx)
if err != nil {

View File

@@ -48,7 +48,7 @@ func (p *PageOpts) GetPageRange(total int, maxButtons int) []int {
// If total pages is less than max buttons, show all pages
if totalPages <= maxButtons {
pages := make([]int, totalPages)
for i := 0; i < totalPages; i++ {
for i := range totalPages {
pages[i] = i + 1
}
return pages

154
internal/db/permission.go Normal file
View File

@@ -0,0 +1,154 @@
package db
import (
"context"
"database/sql"
"git.haelnorr.com/h/oslstats/internal/permissions"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
type Permission struct {
bun.BaseModel `bun:"table:permissions,alias:p"`
ID int `bun:"id,pk,autoincrement"`
Name permissions.Permission `bun:"name,unique,notnull"`
DisplayName string `bun:"display_name,notnull"`
Description string `bun:"description"`
Resource string `bun:"resource,notnull"`
Action string `bun:"action,notnull"`
IsSystem bool `bun:"is_system,default:false"`
CreatedAt int64 `bun:"created_at,notnull"`
}
// GetPermissionByName queries the database for a permission matching the given name
// Returns nil, nil if no permission is found
func GetPermissionByName(ctx context.Context, tx bun.Tx, name permissions.Permission) (*Permission, error) {
if name == "" {
return nil, errors.New("name cannot be empty")
}
perm := new(Permission)
err := tx.NewSelect().
Model(perm).
Where("name = ?", name).
Limit(1).
Scan(ctx)
if err != nil && err != sql.ErrNoRows {
return nil, errors.Wrap(err, "tx.NewSelect")
}
return perm, nil
}
// GetPermissionByID queries the database for a permission matching the given ID
// Returns nil, nil if no permission is found
func GetPermissionByID(ctx context.Context, tx bun.Tx, id int) (*Permission, error) {
if id <= 0 {
return nil, errors.New("id must be positive")
}
perm := new(Permission)
err := tx.NewSelect().
Model(perm).
Where("id = ?", id).
Limit(1).
Scan(ctx)
if err != nil && err != sql.ErrNoRows {
return nil, errors.Wrap(err, "tx.NewSelect")
}
return perm, nil
}
// GetPermissionsByResource queries for all permissions for a given resource
func GetPermissionsByResource(ctx context.Context, tx bun.Tx, resource string) ([]*Permission, error) {
if resource == "" {
return nil, errors.New("resource cannot be empty")
}
var perms []*Permission
err := tx.NewSelect().
Model(&perms).
Where("resource = ?", resource).
Order("action ASC").
Scan(ctx)
if err != nil && err != sql.ErrNoRows {
return nil, errors.Wrap(err, "tx.NewSelect")
}
return perms, nil
}
// GetPermissionsByIDs queries for permissions matching the given IDs
func GetPermissionsByIDs(ctx context.Context, tx bun.Tx, ids []int) ([]*Permission, error) {
if len(ids) == 0 {
return []*Permission{}, nil
}
var perms []*Permission
err := tx.NewSelect().
Model(&perms).
Where("id IN (?)", bun.In(ids)).
Scan(ctx)
if err != nil && err != sql.ErrNoRows {
return nil, errors.Wrap(err, "tx.NewSelect")
}
return perms, nil
}
// ListAllPermissions returns all permissions
func ListAllPermissions(ctx context.Context, tx bun.Tx) ([]*Permission, error) {
var perms []*Permission
err := tx.NewSelect().
Model(&perms).
Order("resource ASC", "action ASC").
Scan(ctx)
if err != nil && err != sql.ErrNoRows {
return nil, errors.Wrap(err, "tx.NewSelect")
}
return perms, nil
}
// CreatePermission creates a new permission
func CreatePermission(ctx context.Context, tx bun.Tx, perm *Permission) error {
if perm == nil {
return errors.New("permission cannot be nil")
}
_, err := tx.NewInsert().
Model(perm).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "tx.NewInsert")
}
return nil
}
// DeletePermission deletes a permission (checks IsSystem protection)
func DeletePermission(ctx context.Context, tx bun.Tx, id int) error {
if id <= 0 {
return errors.New("id must be positive")
}
// Check if permission is system permission
perm, err := GetPermissionByID(ctx, tx, id)
if err != nil {
return errors.Wrap(err, "GetPermissionByID")
}
if perm == nil {
return errors.New("permission not found")
}
if perm.IsSystem {
return errors.New("cannot delete system permission")
}
_, err = tx.NewDelete().
Model((*Permission)(nil)).
Where("id = ?", id).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "tx.NewDelete")
}
return nil
}

204
internal/db/role.go Normal file
View File

@@ -0,0 +1,204 @@
package db
import (
"context"
"database/sql"
"git.haelnorr.com/h/oslstats/internal/roles"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
type Role struct {
bun.BaseModel `bun:"table:roles,alias:r"`
ID int `bun:"id,pk,autoincrement"`
Name roles.Role `bun:"name,unique,notnull"`
DisplayName string `bun:"display_name,notnull"`
Description string `bun:"description"`
IsSystem bool `bun:"is_system,default:false"`
CreatedAt int64 `bun:"created_at,notnull"`
UpdatedAt int64 `bun:"updated_at,notnull"`
// Relations (loaded on demand)
Permissions []*Permission `bun:"m2m:role_permissions,join:Role=Permission"`
}
// GetRoleByName queries the database for a role matching the given name
// Returns nil, nil if no role is found
func GetRoleByName(ctx context.Context, tx bun.Tx, name roles.Role) (*Role, error) {
if name == "" {
return nil, errors.New("name cannot be empty")
}
role := new(Role)
err := tx.NewSelect().
Model(role).
Where("name = ?", name).
Limit(1).
Scan(ctx)
if err != nil && err != sql.ErrNoRows {
return nil, errors.Wrap(err, "tx.NewSelect")
}
return role, nil
}
// GetRoleByID queries the database for a role matching the given ID
// Returns nil, nil if no role is found
func GetRoleByID(ctx context.Context, tx bun.Tx, id int) (*Role, error) {
if id <= 0 {
return nil, errors.New("id must be positive")
}
role := new(Role)
err := tx.NewSelect().
Model(role).
Where("id = ?", id).
Limit(1).
Scan(ctx)
if err != nil && err != sql.ErrNoRows {
return nil, errors.Wrap(err, "tx.NewSelect")
}
return role, nil
}
// GetRoleWithPermissions loads a role and all its permissions
func GetRoleWithPermissions(ctx context.Context, tx bun.Tx, id int) (*Role, error) {
if id <= 0 {
return nil, errors.New("id must be positive")
}
role := new(Role)
err := tx.NewSelect().
Model(role).
Where("id = ?", id).
Relation("Permissions").
Limit(1).
Scan(ctx)
if err != nil && err != sql.ErrNoRows {
return nil, errors.Wrap(err, "tx.NewSelect")
}
return role, nil
}
// ListAllRoles returns all roles
func ListAllRoles(ctx context.Context, tx bun.Tx) ([]*Role, error) {
var roles []*Role
err := tx.NewSelect().
Model(&roles).
Order("name ASC").
Scan(ctx)
if err != nil && err != sql.ErrNoRows {
return nil, errors.Wrap(err, "tx.NewSelect")
}
return roles, nil
}
// CreateRole creates a new role
func CreateRole(ctx context.Context, tx bun.Tx, role *Role) error {
if role == nil {
return errors.New("role cannot be nil")
}
_, err := tx.NewInsert().
Model(role).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "tx.NewInsert")
}
return nil
}
// UpdateRole updates an existing role
func UpdateRole(ctx context.Context, tx bun.Tx, role *Role) error {
if role == nil {
return errors.New("role cannot be nil")
}
if role.ID <= 0 {
return errors.New("role id must be positive")
}
_, err := tx.NewUpdate().
Model(role).
WherePK().
Exec(ctx)
if err != nil {
return errors.Wrap(err, "tx.NewUpdate")
}
return nil
}
// DeleteRole deletes a role (checks IsSystem protection)
func DeleteRole(ctx context.Context, tx bun.Tx, id int) error {
if id <= 0 {
return errors.New("id must be positive")
}
// Check if role is system role
role, err := GetRoleByID(ctx, tx, id)
if err != nil {
return errors.Wrap(err, "GetRoleByID")
}
if role == nil {
return errors.New("role not found")
}
if role.IsSystem {
return errors.New("cannot delete system role")
}
_, err = tx.NewDelete().
Model((*Role)(nil)).
Where("id = ?", id).
Exec(ctx)
if err != nil {
return errors.Wrap(err, "tx.NewDelete")
}
return nil
}
// AddPermissionToRole grants a permission to a role
func AddPermissionToRole(ctx context.Context, tx bun.Tx, roleID, permissionID int, createdAt int64) error {
if roleID <= 0 {
return errors.New("roleID must be positive")
}
if permissionID <= 0 {
return errors.New("permissionID must be positive")
}
// TODO: use proper m2m table
// also make createdAt automatic in table so not required as input here
_, err := tx.ExecContext(ctx, `
INSERT INTO role_permissions (role_id, permission_id, created_at)
VALUES ($1, $2, $3)
ON CONFLICT (role_id, permission_id) DO NOTHING
`, roleID, permissionID, createdAt)
if err != nil {
return errors.Wrap(err, "tx.ExecContext")
}
return nil
}
// RemovePermissionFromRole revokes a permission from a role
func RemovePermissionFromRole(ctx context.Context, tx bun.Tx, roleID, permissionID int) error {
if roleID <= 0 {
return errors.New("roleID must be positive")
}
if permissionID <= 0 {
return errors.New("permissionID must be positive")
}
// TODO: use proper m2m table
_, err := tx.ExecContext(ctx, `
DELETE FROM role_permissions
WHERE role_id = $1 AND permission_id = $2
`, roleID, permissionID)
if err != nil {
return errors.Wrap(err, "tx.ExecContext")
}
return nil
}

View File

@@ -78,7 +78,7 @@ func ListSeasons(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*SeasonLis
total, err := tx.NewSelect().
Model(&seasons).
Count(ctx)
if err != nil && err != sql.ErrNoRows {
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect")
}
sl := &SeasonList{

View File

@@ -2,10 +2,13 @@ package db
import (
"context"
"database/sql"
"fmt"
"time"
"git.haelnorr.com/h/golib/hwsauth"
"git.haelnorr.com/h/oslstats/internal/permissions"
"git.haelnorr.com/h/oslstats/internal/roles"
"github.com/bwmarrin/discordgo"
"github.com/pkg/errors"
"github.com/uptrace/bun"
@@ -22,8 +25,14 @@ type User struct {
DiscordID string `bun:"discord_id,unique"`
}
func (user *User) GetID() int {
return user.ID
type Users struct {
Users []*User
Total int
PageOpts PageOpts
}
func (u *User) GetID() int {
return u.ID
}
// CreateUser creates a new user with the given username and password
@@ -114,3 +123,110 @@ func IsUsernameUnique(ctx context.Context, tx bun.Tx, username string) (bool, er
}
return count == 0, nil
}
// GetRoles loads and returns all roles for this user
func (u *User) GetRoles(ctx context.Context, tx bun.Tx) ([]*Role, error) {
if u == nil {
return nil, errors.New("user cannot be nil")
}
return GetUserRoles(ctx, tx, u.ID)
}
// GetPermissions loads and returns all permissions for this user (via roles)
func (u *User) GetPermissions(ctx context.Context, tx bun.Tx) ([]*Permission, error) {
if u == nil {
return nil, errors.New("user cannot be nil")
}
// TODO: use proper m2m tables and relations instead of join
var permissions []*Permission
err := tx.NewSelect().
Model(&permissions).
Join("JOIN role_permissions AS rp ON rp.permission_id = p.id").
Join("JOIN user_roles AS ur ON ur.role_id = rp.role_id").
Where("ur.user_id = ?", u.ID).
Where("ur.expires_at IS NULL OR ur.expires_at > ?", time.Now().Unix()).
Scan(ctx)
if err != nil && err != sql.ErrNoRows {
return nil, errors.Wrap(err, "tx.NewSelect")
}
return permissions, nil
}
// HasPermission checks if user has a specific permission (including wildcard check)
func (u *User) HasPermission(ctx context.Context, tx bun.Tx, permissionName permissions.Permission) (bool, error) {
if u == nil {
return false, errors.New("user cannot be nil")
}
if permissionName == "" {
return false, errors.New("permissionName cannot be empty")
}
perms, err := u.GetPermissions(ctx, tx)
if err != nil {
return false, err
}
for _, p := range perms {
if p.Name == permissionName || p.Name == permissions.Wildcard {
return true, nil
}
}
return false, nil
}
// HasRole checks if user has a specific role
func (u *User) HasRole(ctx context.Context, tx bun.Tx, roleName roles.Role) (bool, error) {
if u == nil {
return false, errors.New("user cannot be nil")
}
return HasRole(ctx, tx, u.ID, roleName)
}
// IsAdmin is a convenience method to check if user has admin role
func (u *User) IsAdmin(ctx context.Context, tx bun.Tx) (bool, error) {
if u == nil {
return false, errors.New("user cannot be nil")
}
return u.HasRole(ctx, tx, "admin")
}
func GetUsers(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*Users, error) {
if pageOpts == nil {
pageOpts = &PageOpts{}
}
if pageOpts.Page == 0 {
pageOpts.Page = 1
}
if pageOpts.PerPage == 0 {
pageOpts.PerPage = 50
}
if pageOpts.Order == "" {
pageOpts.Order = bun.OrderAsc
}
if pageOpts.OrderBy == "" {
pageOpts.OrderBy = "id"
}
users := []*User{}
err := tx.NewSelect().
Model(users).
OrderBy(pageOpts.OrderBy, pageOpts.Order).
Limit(pageOpts.PerPage).
Offset(pageOpts.PerPage * (pageOpts.Page - 1)).
Scan(ctx)
if err != nil && err != sql.ErrNoRows {
return nil, errors.Wrap(err, "tx.NewSelect")
}
total, err := tx.NewSelect().
Model(users).
Count(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect")
}
list := &Users{
Users: users,
Total: total,
PageOpts: *pageOpts,
}
return list, nil
}

114
internal/db/userrole.go Normal file
View File

@@ -0,0 +1,114 @@
package db
import (
"context"
"time"
"git.haelnorr.com/h/oslstats/internal/roles"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
type UserRole struct {
bun.BaseModel `bun:"table:user_roles,alias:ur"`
ID int `bun:"id,pk,autoincrement"`
UserID int `bun:"user_id,notnull"`
RoleID int `bun:"role_id,notnull"`
GrantedBy *int `bun:"granted_by"`
GrantedAt int64 `bun:"granted_at,notnull"` // TODO: default now
ExpiresAt *int64 `bun:"expires_at"`
// Relations
User *User `bun:"rel:belongs-to,join:user_id=id"`
Role *Role `bun:"rel:belongs-to,join:role_id=id"`
}
// GetUserRoles loads all roles for a given user
func GetUserRoles(ctx context.Context, tx bun.Tx, userID int) ([]*Role, error) {
if userID <= 0 {
return nil, errors.New("userID must be positive")
}
var roles []*Role
err := tx.NewSelect().
Model(&roles).
// TODO: why are we joining? can we do relation?
Join("JOIN user_roles AS ur ON ur.role_id = r.id").
Where("ur.user_id = ?", userID).
Where("ur.expires_at IS NULL OR ur.expires_at > ?", time.Now().Unix()).
Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect")
}
return roles, nil
}
// AssignRole grants a role to a user
func AssignRole(ctx context.Context, tx bun.Tx, userID, roleID int, grantedBy *int) error {
if userID <= 0 {
return errors.New("userID must be positive")
}
if roleID <= 0 {
return errors.New("roleID must be positive")
}
now := time.Now().Unix()
// TODO: use proper m2m table instead of raw SQL
_, err := tx.ExecContext(ctx, `
INSERT INTO user_roles (user_id, role_id, granted_by, granted_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, role_id) DO NOTHING
`, userID, roleID, grantedBy, now)
if err != nil {
return errors.Wrap(err, "tx.ExecContext")
}
return nil
}
// RevokeRole removes a role from a user
func RevokeRole(ctx context.Context, tx bun.Tx, userID, roleID int) error {
if userID <= 0 {
return errors.New("userID must be positive")
}
if roleID <= 0 {
return errors.New("roleID must be positive")
}
// TODO: use proper m2m table instead of raw sql
_, err := tx.ExecContext(ctx, `
DELETE FROM user_roles
WHERE user_id = $1 AND role_id = $2
`, userID, roleID)
if err != nil {
return errors.Wrap(err, "tx.ExecContext")
}
return nil
}
// HasRole checks if a user has a specific role
func HasRole(ctx context.Context, tx bun.Tx, userID int, roleName roles.Role) (bool, error) {
if userID <= 0 {
return false, errors.New("userID must be positive")
}
if roleName == "" {
return false, errors.New("roleName cannot be empty")
}
// TODO: use proper m2m table instead of TableExpr and Join?
count, err := tx.NewSelect().
TableExpr("user_roles AS ur").
Join("JOIN roles AS r ON r.id = ur.role_id").
Where("ur.user_id = ?", userID).
Where("r.name = ?", roleName).
Where("ur.expires_at IS NULL OR ur.expires_at > ?", time.Now().Unix()).
Count(ctx)
if err != nil {
return false, errors.Wrap(err, "tx.NewSelect")
}
return count > 0, nil
}