feat: implement ACL repo and service
Add postgres ACLRepo (List/Get/Set) and ACLService with CanView/CanEdit checks (admin bypass, public flag, creator shortcut, explicit grants) and GetPermissions/SetPermissions for the /acl endpoints. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6c9b1bf1cd
commit
a8ec2a80cb
118
backend/internal/db/postgres/acl_repo.go
Normal file
118
backend/internal/db/postgres/acl_repo.go
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"tanabata/backend/internal/domain"
|
||||||
|
"tanabata/backend/internal/port"
|
||||||
|
)
|
||||||
|
|
||||||
|
type permissionRow struct {
|
||||||
|
UserID int16 `db:"user_id"`
|
||||||
|
UserName string `db:"user_name"`
|
||||||
|
ObjectTypeID int16 `db:"object_type_id"`
|
||||||
|
ObjectID uuid.UUID `db:"object_id"`
|
||||||
|
CanView bool `db:"can_view"`
|
||||||
|
CanEdit bool `db:"can_edit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toPermission(r permissionRow) domain.Permission {
|
||||||
|
return domain.Permission{
|
||||||
|
UserID: r.UserID,
|
||||||
|
UserName: r.UserName,
|
||||||
|
ObjectTypeID: r.ObjectTypeID,
|
||||||
|
ObjectID: r.ObjectID,
|
||||||
|
CanView: r.CanView,
|
||||||
|
CanEdit: r.CanEdit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACLRepo implements port.ACLRepo using PostgreSQL.
|
||||||
|
type ACLRepo struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewACLRepo creates an ACLRepo backed by pool.
|
||||||
|
func NewACLRepo(pool *pgxpool.Pool) *ACLRepo {
|
||||||
|
return &ACLRepo{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ port.ACLRepo = (*ACLRepo)(nil)
|
||||||
|
|
||||||
|
func (r *ACLRepo) List(ctx context.Context, objectTypeID int16, objectID uuid.UUID) ([]domain.Permission, error) {
|
||||||
|
const sql = `
|
||||||
|
SELECT p.user_id, u.name AS user_name, p.object_type_id, p.object_id,
|
||||||
|
p.can_view, p.can_edit
|
||||||
|
FROM acl.permissions p
|
||||||
|
JOIN core.users u ON u.id = p.user_id
|
||||||
|
WHERE p.object_type_id = $1 AND p.object_id = $2
|
||||||
|
ORDER BY u.name`
|
||||||
|
|
||||||
|
q := connOrTx(ctx, r.pool)
|
||||||
|
rows, err := q.Query(ctx, sql, objectTypeID, objectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ACLRepo.List: %w", err)
|
||||||
|
}
|
||||||
|
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[permissionRow])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ACLRepo.List scan: %w", err)
|
||||||
|
}
|
||||||
|
perms := make([]domain.Permission, len(collected))
|
||||||
|
for i, row := range collected {
|
||||||
|
perms[i] = toPermission(row)
|
||||||
|
}
|
||||||
|
return perms, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ACLRepo) Get(ctx context.Context, userID int16, objectTypeID int16, objectID uuid.UUID) (*domain.Permission, error) {
|
||||||
|
const sql = `
|
||||||
|
SELECT p.user_id, u.name AS user_name, p.object_type_id, p.object_id,
|
||||||
|
p.can_view, p.can_edit
|
||||||
|
FROM acl.permissions p
|
||||||
|
JOIN core.users u ON u.id = p.user_id
|
||||||
|
WHERE p.user_id = $1 AND p.object_type_id = $2 AND p.object_id = $3`
|
||||||
|
|
||||||
|
q := connOrTx(ctx, r.pool)
|
||||||
|
rows, err := q.Query(ctx, sql, userID, objectTypeID, objectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ACLRepo.Get: %w", err)
|
||||||
|
}
|
||||||
|
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[permissionRow])
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("ACLRepo.Get scan: %w", err)
|
||||||
|
}
|
||||||
|
p := toPermission(row)
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ACLRepo) Set(ctx context.Context, objectTypeID int16, objectID uuid.UUID, perms []domain.Permission) error {
|
||||||
|
q := connOrTx(ctx, r.pool)
|
||||||
|
|
||||||
|
const del = `DELETE FROM acl.permissions WHERE object_type_id = $1 AND object_id = $2`
|
||||||
|
if _, err := q.Exec(ctx, del, objectTypeID, objectID); err != nil {
|
||||||
|
return fmt.Errorf("ACLRepo.Set delete: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(perms) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const ins = `
|
||||||
|
INSERT INTO acl.permissions (user_id, object_type_id, object_id, can_view, can_edit)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`
|
||||||
|
for _, p := range perms {
|
||||||
|
if _, err := q.Exec(ctx, ins, p.UserID, objectTypeID, objectID, p.CanView, p.CanEdit); err != nil {
|
||||||
|
return fmt.Errorf("ACLRepo.Set insert: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
81
backend/internal/service/acl_service.go
Normal file
81
backend/internal/service/acl_service.go
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"tanabata/backend/internal/domain"
|
||||||
|
"tanabata/backend/internal/port"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ACLService handles access control checks and permission management.
|
||||||
|
type ACLService struct {
|
||||||
|
aclRepo port.ACLRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewACLService(aclRepo port.ACLRepo) *ACLService {
|
||||||
|
return &ACLService{aclRepo: aclRepo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanView returns true if the user may view the object.
|
||||||
|
// isAdmin, creatorID, isPublic must be populated from the object record by the caller.
|
||||||
|
func (s *ACLService) CanView(
|
||||||
|
ctx context.Context,
|
||||||
|
userID int16, isAdmin bool,
|
||||||
|
creatorID int16, isPublic bool,
|
||||||
|
objectTypeID int16, objectID uuid.UUID,
|
||||||
|
) (bool, error) {
|
||||||
|
if isAdmin {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if isPublic {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if userID == creatorID {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
perm, err := s.aclRepo.Get(ctx, userID, objectTypeID, objectID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrNotFound) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return perm.CanView, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanEdit returns true if the user may edit the object.
|
||||||
|
// is_public does not grant edit access; only admins, creators, and explicit grants.
|
||||||
|
func (s *ACLService) CanEdit(
|
||||||
|
ctx context.Context,
|
||||||
|
userID int16, isAdmin bool,
|
||||||
|
creatorID int16,
|
||||||
|
objectTypeID int16, objectID uuid.UUID,
|
||||||
|
) (bool, error) {
|
||||||
|
if isAdmin {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if userID == creatorID {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
perm, err := s.aclRepo.Get(ctx, userID, objectTypeID, objectID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrNotFound) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return perm.CanEdit, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPermissions returns all explicit ACL entries for an object.
|
||||||
|
func (s *ACLService) GetPermissions(ctx context.Context, objectTypeID int16, objectID uuid.UUID) ([]domain.Permission, error) {
|
||||||
|
return s.aclRepo.List(ctx, objectTypeID, objectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPermissions replaces all ACL entries for an object (full replace semantics).
|
||||||
|
func (s *ACLService) SetPermissions(ctx context.Context, objectTypeID int16, objectID uuid.UUID, perms []domain.Permission) error {
|
||||||
|
return s.aclRepo.Set(ctx, objectTypeID, objectID, perms)
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user