diff --git a/model/permission.go b/model/permission.go new file mode 100644 index 0000000..9fe95c7 --- /dev/null +++ b/model/permission.go @@ -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", +} diff --git a/repository/permission.go b/repository/permission.go new file mode 100644 index 0000000..696e521 --- /dev/null +++ b/repository/permission.go @@ -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 +}