AccessGrantRepository:
- CRUD (List/Create/Get/Delete) с UPSERT на (resource, subject) для
идемпотентности повторных grant'ов.
- ResolveVisibleResourceIDs(viewer ViewerContext) — для данного юзера
возвращает DISTINCT set resource_id'шников, выданных через любой из
subject_type: 'public' OR 'user'==viewer OR 'role'∈viewer.RoleIDs OR
'department'==viewer.DepartmentID OR 'position'==viewer.PositionID.
ViewerContext собирается из X-User-Roles/Department-Id/Position-Id
headers'ов (portal-gateway прокидывает после JWT-валидации).
AccessGrantHandler:
- GET /access/{resourceType}/{resourceId} — list (owner-only).
- POST /access/{resourceType}/{resourceId} — выдать (UPSERT).
- DELETE /access/{resourceType}/{resourceId}/grants/{grantId} —
отозвать. resourceType/Id дублируются в URL'е для cross-check'а
чтобы owner ресурса A не мог удалить grant ресурса B по grantId.
Интеграция в List'ах:
- TestHandler.List: ?mine=true работает как было; без mine видны
published + дозалив unpublished, выданных через access_grants.
- CourseHandler.List: то же поведение зеркально.
Семантика union'а: «published all + grant-only». Это backward-compat
(старые published продолжают быть видны всем), при этом HR может
явно выдать draft-ресурс конкретному юзеру/роли без публикации.
helpers.go: viewerContextFromHeaders — парсит X-User-Roles (CSV),
X-User-Department-Id, X-User-Position-Id; невалидные/пустые → default.
Wire-up: accessRepo внедрён в Test/Course handler'ы; accessH
зарегистрирован вместо предыдущей 501-заглушки.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
181 lines
6.5 KiB
Go
181 lines
6.5 KiB
Go
package repository
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
||
"learning-service/internal/model"
|
||
)
|
||
|
||
type AccessGrantRepository struct {
|
||
pool *pgxpool.Pool
|
||
}
|
||
|
||
func NewAccessGrantRepository(pool *pgxpool.Pool) *AccessGrantRepository {
|
||
return &AccessGrantRepository{pool: pool}
|
||
}
|
||
|
||
const grantCols = `
|
||
id, resource_type, resource_id, subject_type, subject_id,
|
||
can_manage, granted_by, created_at
|
||
`
|
||
|
||
func scanGrant(scan func(...any) error) (*model.AccessGrant, error) {
|
||
var g model.AccessGrant
|
||
if err := scan(
|
||
&g.ID, &g.ResourceType, &g.ResourceID, &g.SubjectType, &g.SubjectID,
|
||
&g.CanManage, &g.GrantedBy, &g.CreatedAt,
|
||
); err != nil {
|
||
return nil, err
|
||
}
|
||
return &g, nil
|
||
}
|
||
|
||
// List — все гранты на ресурс. Для HR-UI «кому я разрешил».
|
||
func (r *AccessGrantRepository) List(ctx context.Context, resourceType string, resourceID uuid.UUID) ([]model.AccessGrant, error) {
|
||
rows, err := r.pool.Query(ctx,
|
||
`SELECT `+grantCols+` FROM access_grants
|
||
WHERE resource_type = $1 AND resource_id = $2
|
||
ORDER BY created_at DESC`,
|
||
resourceType, resourceID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := []model.AccessGrant{}
|
||
for rows.Next() {
|
||
g, err := scanGrant(rows.Scan)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, *g)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// Create — выдать доступ. ON CONFLICT (resource_type, resource_id,
|
||
// subject_type, subject_id) DO NOTHING — повторная выдача того же гранта
|
||
// идемпотентна. can_manage обновляется через отдельный Update.
|
||
func (r *AccessGrantRepository) Create(ctx context.Context, resourceType string, resourceID uuid.UUID, req model.CreateAccessGrantRequest, grantedBy uuid.UUID) (*model.AccessGrant, error) {
|
||
if req.SubjectType == "public" && req.SubjectID != nil {
|
||
return nil, fmt.Errorf("subject_id must be NULL for public grants")
|
||
}
|
||
if req.SubjectType != "public" && req.SubjectID == nil {
|
||
return nil, fmt.Errorf("subject_id is required for %s grants", req.SubjectType)
|
||
}
|
||
var id uuid.UUID
|
||
err := r.pool.QueryRow(ctx, `
|
||
INSERT INTO access_grants (
|
||
resource_type, resource_id, subject_type, subject_id, can_manage, granted_by
|
||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||
ON CONFLICT (resource_type, resource_id, subject_type, subject_id) DO UPDATE
|
||
SET can_manage = EXCLUDED.can_manage
|
||
RETURNING id`,
|
||
resourceType, resourceID, req.SubjectType, req.SubjectID,
|
||
req.CanManage, grantedBy,
|
||
).Scan(&id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return r.Get(ctx, id)
|
||
}
|
||
|
||
func (r *AccessGrantRepository) Get(ctx context.Context, id uuid.UUID) (*model.AccessGrant, error) {
|
||
g, err := scanGrant(r.pool.QueryRow(ctx,
|
||
`SELECT `+grantCols+` FROM access_grants WHERE id = $1`, id).Scan)
|
||
if err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
return nil, err
|
||
}
|
||
return g, nil
|
||
}
|
||
|
||
// Delete — отзыв доступа. Сам объект remove'ится из БД.
|
||
func (r *AccessGrantRepository) Delete(ctx context.Context, id uuid.UUID) error {
|
||
tag, err := r.pool.Exec(ctx, `DELETE FROM access_grants WHERE id = $1`, id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return ErrNotFound
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ViewerContext — субъекты, которые «представляет» конкретный юзер при
|
||
// проверке доступа. Заполняется handler'ом из X-User-* headers (portal-
|
||
// gateway знает permission/role/department/position юзера, прокидывает).
|
||
//
|
||
// UserID должен быть всегда; роли/dept/position — могут быть пустыми
|
||
// (тогда соответствующие grants не сматчатся, что корректно).
|
||
type ViewerContext struct {
|
||
UserID uuid.UUID
|
||
RoleIDs []uuid.UUID
|
||
DepartmentID *uuid.UUID
|
||
PositionID *uuid.UUID
|
||
}
|
||
|
||
// ResolveVisibleResourceIDs — для данного viewer'а возвращает множество
|
||
// resource_id'шников, которым он разрешён через access_grants.
|
||
// resource_type фильтрует ('test' | 'course').
|
||
//
|
||
// Логика SQL: WHERE (subject_type='public') OR
|
||
// (subject_type='user' AND subject_id = viewer_id) OR
|
||
// (subject_type='role' AND subject_id = ANY(viewer.roles)) OR
|
||
// (subject_type='department' AND subject_id = viewer.department) OR
|
||
// (subject_type='position' AND subject_id = viewer.position)
|
||
//
|
||
// Возвращается distinct set — один и тот же ресурс может быть выдан
|
||
// несколькими grants одновременно (например, и по роли, и по
|
||
// department'у); в результате он окажется один раз.
|
||
//
|
||
// Этот метод используется в Tests.List / Courses.List как visibleIDs-
|
||
// фильтр (см. repository.TestRepository.List signature).
|
||
func (r *AccessGrantRepository) ResolveVisibleResourceIDs(ctx context.Context, resourceType string, viewer ViewerContext) ([]uuid.UUID, error) {
|
||
// Собираем conds OR'ом. Empty viewer.RoleIDs пропускаем чтобы не
|
||
// генерить пустой ANY('{}') (он валиден, но бессмысленнен).
|
||
conds := []string{"subject_type = 'public'"}
|
||
args := []any{resourceType, viewer.UserID}
|
||
// $1 = resource_type, $2 = user_id, далее по индексу.
|
||
conds = append(conds, "(subject_type = 'user' AND subject_id = $2)")
|
||
if len(viewer.RoleIDs) > 0 {
|
||
args = append(args, viewer.RoleIDs)
|
||
conds = append(conds, fmt.Sprintf("(subject_type = 'role' AND subject_id = ANY($%d))", len(args)))
|
||
}
|
||
if viewer.DepartmentID != nil {
|
||
args = append(args, *viewer.DepartmentID)
|
||
conds = append(conds, fmt.Sprintf("(subject_type = 'department' AND subject_id = $%d)", len(args)))
|
||
}
|
||
if viewer.PositionID != nil {
|
||
args = append(args, *viewer.PositionID)
|
||
conds = append(conds, fmt.Sprintf("(subject_type = 'position' AND subject_id = $%d)", len(args)))
|
||
}
|
||
sql := fmt.Sprintf(`
|
||
SELECT DISTINCT resource_id
|
||
FROM access_grants
|
||
WHERE resource_type = $1
|
||
AND (%s)`, strings.Join(conds, " OR "))
|
||
rows, err := r.pool.Query(ctx, sql, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := []uuid.UUID{}
|
||
for rows.Next() {
|
||
var id uuid.UUID
|
||
if err := rows.Scan(&id); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, id)
|
||
}
|
||
return out, rows.Err()
|
||
}
|