Compare commits
No commits in common. "ee251c8727bb3939e1c2fe94bc7581a8338684da" and "6c9b1bf1cd6f995e30b8b020c09adab37d831c38" have entirely different histories.
ee251c8727
...
6c9b1bf1cd
@ -1,118 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,169 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/db"
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// auditRowWithTotal matches the columns returned by the audit log SELECT.
|
|
||||||
// object_type is nullable (LEFT JOIN), object_id and details are nullable columns.
|
|
||||||
type auditRowWithTotal struct {
|
|
||||||
ID int64 `db:"id"`
|
|
||||||
UserID int16 `db:"user_id"`
|
|
||||||
UserName string `db:"user_name"`
|
|
||||||
Action string `db:"action"`
|
|
||||||
ObjectType *string `db:"object_type"`
|
|
||||||
ObjectID *uuid.UUID `db:"object_id"`
|
|
||||||
Details json.RawMessage `db:"details"`
|
|
||||||
PerformedAt time.Time `db:"performed_at"`
|
|
||||||
Total int `db:"total"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func toAuditEntry(r auditRowWithTotal) domain.AuditEntry {
|
|
||||||
return domain.AuditEntry{
|
|
||||||
ID: r.ID,
|
|
||||||
UserID: r.UserID,
|
|
||||||
UserName: r.UserName,
|
|
||||||
Action: r.Action,
|
|
||||||
ObjectType: r.ObjectType,
|
|
||||||
ObjectID: r.ObjectID,
|
|
||||||
Details: r.Details,
|
|
||||||
PerformedAt: r.PerformedAt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuditRepo implements port.AuditRepo using PostgreSQL.
|
|
||||||
type AuditRepo struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAuditRepo creates an AuditRepo backed by pool.
|
|
||||||
func NewAuditRepo(pool *pgxpool.Pool) *AuditRepo {
|
|
||||||
return &AuditRepo{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ port.AuditRepo = (*AuditRepo)(nil)
|
|
||||||
|
|
||||||
// Log inserts one audit record. action_type_id and object_type_id are resolved
|
|
||||||
// from the reference tables inside the INSERT via subqueries.
|
|
||||||
func (r *AuditRepo) Log(ctx context.Context, entry domain.AuditEntry) error {
|
|
||||||
const sql = `
|
|
||||||
INSERT INTO activity.audit_log
|
|
||||||
(user_id, action_type_id, object_type_id, object_id, details)
|
|
||||||
VALUES (
|
|
||||||
$1,
|
|
||||||
(SELECT id FROM activity.action_types WHERE name = $2),
|
|
||||||
CASE WHEN $3::text IS NOT NULL
|
|
||||||
THEN (SELECT id FROM core.object_types WHERE name = $3)
|
|
||||||
ELSE NULL END,
|
|
||||||
$4,
|
|
||||||
$5
|
|
||||||
)`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
_, err := q.Exec(ctx, sql,
|
|
||||||
entry.UserID,
|
|
||||||
entry.Action,
|
|
||||||
entry.ObjectType,
|
|
||||||
entry.ObjectID,
|
|
||||||
entry.Details,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("AuditRepo.Log: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// List returns a filtered, offset-paginated page of audit log entries ordered
|
|
||||||
// newest-first.
|
|
||||||
func (r *AuditRepo) List(ctx context.Context, filter domain.AuditFilter) (*domain.AuditPage, error) {
|
|
||||||
var conds []string
|
|
||||||
args := make([]any, 0, 8)
|
|
||||||
n := 1
|
|
||||||
|
|
||||||
if filter.UserID != nil {
|
|
||||||
conds = append(conds, fmt.Sprintf("a.user_id = $%d", n))
|
|
||||||
args = append(args, *filter.UserID)
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
if filter.Action != "" {
|
|
||||||
conds = append(conds, fmt.Sprintf("at.name = $%d", n))
|
|
||||||
args = append(args, filter.Action)
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
if filter.ObjectType != "" {
|
|
||||||
conds = append(conds, fmt.Sprintf("ot.name = $%d", n))
|
|
||||||
args = append(args, filter.ObjectType)
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
if filter.ObjectID != nil {
|
|
||||||
conds = append(conds, fmt.Sprintf("a.object_id = $%d", n))
|
|
||||||
args = append(args, *filter.ObjectID)
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
if filter.From != nil {
|
|
||||||
conds = append(conds, fmt.Sprintf("a.performed_at >= $%d", n))
|
|
||||||
args = append(args, *filter.From)
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
if filter.To != nil {
|
|
||||||
conds = append(conds, fmt.Sprintf("a.performed_at <= $%d", n))
|
|
||||||
args = append(args, *filter.To)
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
|
|
||||||
where := ""
|
|
||||||
if len(conds) > 0 {
|
|
||||||
where = "WHERE " + strings.Join(conds, " AND ")
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := db.ClampLimit(filter.Limit, 50, 200)
|
|
||||||
offset := db.ClampOffset(filter.Offset)
|
|
||||||
args = append(args, limit, offset)
|
|
||||||
|
|
||||||
sql := fmt.Sprintf(`
|
|
||||||
SELECT a.id, a.user_id, u.name AS user_name,
|
|
||||||
at.name AS action,
|
|
||||||
ot.name AS object_type,
|
|
||||||
a.object_id, a.details,
|
|
||||||
a.performed_at,
|
|
||||||
COUNT(*) OVER() AS total
|
|
||||||
FROM activity.audit_log a
|
|
||||||
JOIN core.users u ON u.id = a.user_id
|
|
||||||
JOIN activity.action_types at ON at.id = a.action_type_id
|
|
||||||
LEFT JOIN core.object_types ot ON ot.id = a.object_type_id
|
|
||||||
%s
|
|
||||||
ORDER BY a.performed_at DESC
|
|
||||||
LIMIT $%d OFFSET $%d`, where, n, n+1)
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("AuditRepo.List: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[auditRowWithTotal])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("AuditRepo.List scan: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
page := &domain.AuditPage{Offset: offset, Limit: limit}
|
|
||||||
if len(collected) > 0 {
|
|
||||||
page.Total = collected[0].Total
|
|
||||||
}
|
|
||||||
page.Items = make([]domain.AuditEntry, len(collected))
|
|
||||||
for i, row := range collected {
|
|
||||||
page.Items[i] = toAuditEntry(row)
|
|
||||||
}
|
|
||||||
return page, nil
|
|
||||||
}
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AuditService records user actions to the audit trail.
|
|
||||||
type AuditService struct {
|
|
||||||
repo port.AuditRepo
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAuditService(repo port.AuditRepo) *AuditService {
|
|
||||||
return &AuditService{repo: repo}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log records an action performed by the user in ctx.
|
|
||||||
// objectType and objectID are optional — pass nil when the action has no target object.
|
|
||||||
// details can be any JSON-serializable value, or nil.
|
|
||||||
func (s *AuditService) Log(
|
|
||||||
ctx context.Context,
|
|
||||||
action string,
|
|
||||||
objectType *string,
|
|
||||||
objectID *uuid.UUID,
|
|
||||||
details any,
|
|
||||||
) error {
|
|
||||||
userID, _, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
var raw json.RawMessage
|
|
||||||
if details != nil {
|
|
||||||
b, err := json.Marshal(details)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("AuditService.Log marshal details: %w", err)
|
|
||||||
}
|
|
||||||
raw = b
|
|
||||||
}
|
|
||||||
|
|
||||||
entry := domain.AuditEntry{
|
|
||||||
UserID: userID,
|
|
||||||
Action: action,
|
|
||||||
ObjectType: objectType,
|
|
||||||
ObjectID: objectID,
|
|
||||||
Details: raw,
|
|
||||||
}
|
|
||||||
return s.repo.Log(ctx, entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Query returns a filtered, paginated page of audit log entries.
|
|
||||||
func (s *AuditService) Query(ctx context.Context, filter domain.AuditFilter) (*domain.AuditPage, error) {
|
|
||||||
return s.repo.List(ctx, filter)
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user