feat(model,repo,permission): permissions repository and model

This commit is contained in:
Guz
2025-06-26 19:11:11 -03:00
parent 8ad87ea2e3
commit e8b429720b
2 changed files with 428 additions and 0 deletions

145
model/permission.go Normal file
View File

@@ -0,0 +1,145 @@
package model
import (
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"strconv"
"strings"
)
type Permissions int64
var (
_ sql.Scanner = (*Permissions)(nil)
_ driver.Value = Permissions(0)
_ fmt.Stringer = Permissions(0)
)
func (p Permissions) Has(perm ...Permissions) bool {
// Bitwise AND to compare if p has a permission
//
// If for example, p is 0x0010 ("edit.accessibility") and perm is
// 0x0001 ("read"): 0x0010 AND 0x0001 = 0x0000, which is not equal
// to 0x0001, return false.
//
// If p is 0x0011 ("edit.accessibility" and "read") and perm is
// 0x0001 ("read"): 0x0011 AND 0x0001 results in 0x0001, which
// is equal to 0x0001 ("read").
if len(perm) == 0 {
return false
}
if len(perm) == 1 {
return p&perm[0] == perm[0]
}
for _, pe := range perm {
if p&pe != pe {
return false
}
}
return true
}
func (p *Permissions) Add(perm ...Permissions) {
if p == nil {
t := Permissions(0)
p = &t
}
// Bitwise OR to add permissions.
//
// If p is 0x0001 ("read") and pe is 0x0010 ("edit.accessibility"):
// 0x0001 OR 0x0010 results in 0x0011, which means we added the "edit.accessibility" bit.
for _, pe := range perm {
*p = *p | pe
}
}
func (p *Permissions) Remove(perm ...Permissions) {
if p == nil {
return
}
// Bitwise NOT AND
//
// If p is 0x0011 ("read" + "edit.accessibility"), and perm is 0x0010 ("edit.accessibility"):
// we first convert perm to a bit-mask using NOT, so it becomes 0x1101; then we use AND to
// remove the "edit.accessibility", since 0x0011 AND 0x1101 results in 0x0001 ("read").
for _, pe := range perm {
*p = *p & (^pe)
}
}
func (p *Permissions) Scan(src any) error {
switch src := src.(type) {
case nil:
return nil
case int64:
*p = Permissions(src)
case string:
if strings.HasPrefix(src, "0x") {
i, err := strconv.ParseInt(strings.TrimPrefix(src, "0x"), 2, 64)
if err != nil {
return errors.Join(errors.New("Scan: unable to scan binary Permissions"), err)
}
return p.Scan(i)
}
i, err := strconv.ParseInt(src, 10, 64)
if err != nil {
return errors.Join(errors.New("Scan: unable to scan base10 Permissions"), err)
}
return p.Scan(i)
case []byte:
return p.Scan(string(src))
default:
return fmt.Errorf("Scan: unable to scan type %T into Permissions", src)
}
return nil
}
func (p Permissions) Value() (driver.Value, error) {
return int64(p), nil
}
func (p Permissions) String() string {
if p.Has(PermissionAuthor) {
return "author"
}
labels := []string{}
for perm, l := range PermissionLabels {
if p.Has(perm) {
labels = append(labels, l)
}
}
return strings.Join(labels, ",")
}
const (
PermissionAuthor Permissions = 0x1111111111111111 // "author"
PermissionAdminDelete Permissions = 0x1000000000000000 // "admin.delete" -----
PermissionAdminAll Permissions = 0x0111110000000001 // "admin.all"
PermissionAdminProject Permissions = 0x0100000000000000 // "admin.project"
PermissionAdminMembers Permissions = 0x0010000000000000 // "admin.members"
PermissionEditAll Permissions = 0x0000001111111111 // "edit.all" ---------
PermissionEditPages Permissions = 0x0000000100000000 // "edit.pages"
PermissionEditInteractions Permissions = 0x0000000010000000 // "edit.interactions"
PermissionEditDialogs Permissions = 0x0000000000001000 // "edit.dialogs"
PermissionEditTranslations Permissions = 0x0000000000000100 // "edit.translations"
PermissionEditAccessibility Permissions = 0x0000000000000010 // "edit.accessibility"
PermissionRead Permissions = 0x0000000000000001 // "read"
)
var PermissionLabels = map[Permissions]string{
PermissionAuthor: "author",
PermissionAdminDelete: "admin.delete",
PermissionAdminProject: "admin.project",
PermissionAdminMembers: "admin.members",
PermissionEditPages: "edit.pages",
PermissionEditInteractions: "edit.interactions",
PermissionEditDialogs: "edit.dialogs",
PermissionEditTranslations: "edit.translations",
PermissionEditAccessibility: "edit.accessibility",
PermissionRead: "read",
}

283
repository/permission.go Normal file
View File

@@ -0,0 +1,283 @@
package repository
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"time"
"forge.capytal.company/capytalcode/project-comicverse/model"
"forge.capytal.company/loreddev/x/tinyssert"
"github.com/google/uuid"
)
type Permissions struct {
baseRepostiory
}
// Must be initiated after [User] and [Project]
func NewPermissions(
ctx context.Context,
db *sql.DB,
log *slog.Logger,
assert tinyssert.Assertions,
) (*Permissions, error) {
b := newBaseRepostiory(ctx, db, log, assert)
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
q := fmt.Sprintf(`
CREATE TABLE IF NOT EXISTS project_permissions (
project_id TEXT NOT NULL,
user_id TEXT NOT NULL,
permissions_value INTEGER NOT NULL DEFAULT '0',
_permissions_text TEXT NOT NULL DEFAULT '', -- For display purposes only, may not always be up-to-date
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY(project_id, user_id)
FOREIGN KEY(project_id)
REFERENCES projects (id)
ON DELETE CASCADE
ON UPDATE RESTRICT,
FOREIGN KEY(user_id)
REFERENCES users (id)
ON DELETE CASCADE
ON UPDATE RESTRICT
)
`)
_, err = tx.ExecContext(ctx, q)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, errors.Join(errors.New("unable to create project tables"), err)
}
return &Permissions{baseRepostiory: b}, nil
}
func (repo Permissions) Create(project, user uuid.UUID, permissions model.Permissions) error {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.ctx)
tx, err := repo.db.BeginTx(repo.ctx, nil)
if err != nil {
return errors.Join(ErrDatabaseConn, err)
}
q := `
INSERT INTO project_permissions (project_id, user_id, permissions_value, _permissions_text, created_at, updated_at)
VALUES (:project_id, :user_id, :permissions_value, :permissions_text, :created_at, :updated_at)
`
now := time.Now()
log := repo.log.With(slog.String("project_id", project.String()),
slog.String("user_id", user.String()),
slog.String("permissions", fmt.Sprintf("%d", permissions)),
slog.String("permissions_text", permissions.String()),
slog.String("query", q))
log.DebugContext(repo.ctx, "Inserting new project permissions")
_, err = tx.ExecContext(repo.ctx, q,
sql.Named("project_id", project),
sql.Named("user_id", user),
sql.Named("permissions_value", permissions),
sql.Named("permissions_text", permissions.String()),
sql.Named("created_at", now.Format(dateFormat)),
sql.Named("updated_at", now.Format(dateFormat)),
)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to insert project permissions", slog.String("error", err.Error()))
return errors.Join(ErrExecuteQuery, err)
}
if err := tx.Commit(); err != nil {
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
return errors.Join(ErrCommitQuery, err)
}
return nil
}
func (repo Permissions) GetByID(project uuid.UUID, user uuid.UUID) (model.Permissions, error) {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.log)
q := `
SELECT permissions_value FROM project_permissions
WHERE project_id = :project_id
AND user_id = :user_id
`
log := repo.log.With(slog.String("projcet_id", project.String()),
slog.String("user_id", user.String()),
slog.String("query", q))
log.DebugContext(repo.ctx, "Getting by ID")
row := repo.db.QueryRowContext(repo.ctx, q,
sql.Named("project_id", user),
sql.Named("user_id", user))
var p model.Permissions
if err := row.Scan(&p); err != nil {
log.ErrorContext(repo.ctx, "Failed to get permissions by ID", slog.String("error", err.Error()))
return model.Permissions(0), errors.Join(ErrExecuteQuery, err)
}
return p, nil
}
// GetByUserID returns a project_id-to-permissions map containing all projects and permissions that said userID
// has relation to.
func (repo Permissions) GetByUserID(user uuid.UUID) (permissions map[uuid.UUID]model.Permissions, err error) {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.log)
// Begin tx so we don't read rows as they are being updated
tx, err := repo.db.BeginTx(repo.ctx, nil)
if err != nil {
return nil, errors.Join(ErrDatabaseConn, err)
}
q := `
SELECT project_id, permissions_value FROM project_permissions
WHERE user_id = :user_id
`
log := repo.log.With(slog.String("user_id", user.String()),
slog.String("query", q))
log.DebugContext(repo.ctx, "Getting by user ID")
rows, err := tx.QueryContext(repo.ctx, q, sql.Named("user_id", user))
if err != nil {
log.ErrorContext(repo.ctx, "Failed to get permissions by user ID", slog.String("error", err.Error()))
return nil, errors.Join(ErrExecuteQuery, err)
}
defer func() {
err = rows.Close()
if err != nil {
err = errors.Join(ErrCloseConn, err)
}
}()
ps := map[uuid.UUID]model.Permissions{}
for rows.Next() {
var project uuid.UUID
var permissions model.Permissions
err := rows.Scan(&project, &permissions)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to scan permissions of user id", slog.String("error", err.Error()))
return nil, errors.Join(ErrInvalidOutput, err)
}
ps[project] = permissions
}
if err := tx.Commit(); err != nil {
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
return nil, errors.Join(ErrCommitQuery, err)
}
return ps, nil
}
func (repo Permissions) Update(project, user uuid.UUID, permissions model.Permissions) error {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.log)
tx, err := repo.db.BeginTx(repo.ctx, nil)
if err != nil {
return errors.Join(ErrDatabaseConn, err)
}
q := `
UPDATE project_permissions
SET permissions_value = :permissions_value
_permissions_text = :permissions_text
updated_at = :updated_at
WHERE project_uuid = :project_uuid
AND user_uuid = :user_uuid
`
log := repo.log.With(slog.String("project_id", project.String()),
slog.String("user_id", user.String()),
slog.String("permissions", fmt.Sprintf("%d", permissions)),
slog.String("permissions_text", permissions.String()),
slog.String("query", q))
log.DebugContext(repo.ctx, "Updating project permissions")
now := time.Now()
_, err = tx.ExecContext(repo.ctx, q,
sql.Named("permissions_value", permissions),
sql.Named("permissions_text", permissions.String()),
sql.Named("updated_at", now.Format(dateFormat)),
sql.Named("project_id", project),
sql.Named("user_id", user),
)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to update project permissions", slog.String("error", err.Error()))
return errors.Join(ErrExecuteQuery, err)
}
if err := tx.Commit(); err != nil {
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
return errors.Join(ErrCommitQuery, err)
}
return nil
}
func (repo Permissions) Delete(project, user uuid.UUID) error {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.ctx)
tx, err := repo.db.BeginTx(repo.ctx, nil)
if err != nil {
return err
}
q := `
DELETE FROM project_permissions
WHERE project_id = :project_id
AND user_id = :user_id
`
log := repo.log.With(slog.String("project_id", project.String()),
slog.String("user_id", user.String()),
slog.String("query", q))
log.DebugContext(repo.ctx, "Deleting project permissions")
_, err = tx.ExecContext(repo.ctx, q,
sql.Named("project_id", project),
sql.Named("user_id", user),
)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to delete project permissions", slog.String("error", err.Error()))
return errors.Join(ErrExecuteQuery, err)
}
if err := tx.Commit(); err != nil {
log.ErrorContext(repo.ctx, "Failed to commit transaction", slog.String("error", err.Error()))
return errors.Join(ErrCommitQuery, err)
}
return nil
}