From 89abcc1718b033cdb38dda0b81f6e25010cef2c8 Mon Sep 17 00:00:00 2001 From: Ilya Date: Tue, 26 May 2026 01:17:42 +0300 Subject: [PATCH] =?UTF-8?q?feat(access):=20=D0=B3=D1=80=D0=B0=D0=BD=D1=83?= =?UTF-8?q?=D0=BB=D1=8F=D1=80=D0=BD=D1=8B=D0=B5=20=D0=B4=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=83=D0=BF=D1=8B=20(access=5Fgrants)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/server/main.go | 13 +- internal/handler/access_grant.go | 150 +++++++++++++++++++++++ internal/handler/course.go | 52 ++++++-- internal/handler/helpers.go | 32 +++++ internal/handler/test.go | 67 +++++++++-- internal/repository/access_grant.go | 180 ++++++++++++++++++++++++++++ 6 files changed, 468 insertions(+), 26 deletions(-) create mode 100644 internal/handler/access_grant.go create mode 100644 internal/repository/access_grant.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 150bd71..1b2dbaf 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -71,13 +71,15 @@ func main() { courseRepo := repository.NewCourseRepository(pool) lessonRepo := repository.NewLessonRepository(pool) publicTokenRepo := repository.NewPublicTokenRepository(pool) + accessRepo := repository.NewAccessGrantRepository(pool) healthH := handler.NewHealthHandler(pool) - testH := handler.NewTestHandler(testRepo) + testH := handler.NewTestHandler(testRepo, accessRepo) attemptH := handler.NewAttemptHandler(attemptRepo, testRepo) - courseH := handler.NewCourseHandler(courseRepo) + courseH := handler.NewCourseHandler(courseRepo, accessRepo) lessonH := handler.NewLessonHandler(lessonRepo, courseRepo, store) publicTokenH := handler.NewPublicTokenHandler(publicTokenRepo, testRepo, courseRepo, attemptRepo) + accessH := handler.NewAccessGrantHandler(accessRepo, testRepo, courseRepo) r := chi.NewRouter() r.Use(chimw.RequestID) @@ -137,7 +139,12 @@ func main() { r.Post("/lessons/{id}/video", lessonH.UploadVideo) r.Get("/lessons/{id}/video/stream", lessonH.StreamVideo) r.Delete("/lessons/{id}/video", lessonH.DeleteVideo) - r.HandleFunc("/access/{resourceType}/{resourceId}", notImplemented) + // Access grants — гранулярные доступы (user/role/department/position/public). + // Управляет владелец ресурса; SubjectIDs матчатся по X-User-Roles/ + // Department/Position-headers'ам, прокидываемым portal-gateway'ом. + r.Get("/access/{resourceType}/{resourceId}", accessH.List) + r.Post("/access/{resourceType}/{resourceId}", accessH.Create) + r.Delete("/access/{resourceType}/{resourceId}/grants/{grantId}", accessH.Delete) // Public tokens — HR-side: создать ссылку для кандидата, посмотреть // список, отозвать. Сам прохождение тестa по токену — в /public ниже. diff --git a/internal/handler/access_grant.go b/internal/handler/access_grant.go new file mode 100644 index 0000000..0f6690d --- /dev/null +++ b/internal/handler/access_grant.go @@ -0,0 +1,150 @@ +package handler + +import ( + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "learning-service/internal/model" + "learning-service/internal/repository" +) + +type AccessGrantHandler struct { + repo *repository.AccessGrantRepository + testRepo *repository.TestRepository + courseRepo *repository.CourseRepository +} + +func NewAccessGrantHandler( + repo *repository.AccessGrantRepository, + testRepo *repository.TestRepository, + courseRepo *repository.CourseRepository, +) *AccessGrantHandler { + return &AccessGrantHandler{repo: repo, testRepo: testRepo, courseRepo: courseRepo} +} + +// authorizeResourceOwner — проверка прав на управление access_grants: +// только owner ресурса (или admin через bypass на портале). Owner +// автоматически имеет доступ ко всему, гранты для него не нужны — +// они только для «других». +func (h *AccessGrantHandler) authorizeResourceOwner(w http.ResponseWriter, r *http.Request, resourceType string, resourceID uuid.UUID) (uuid.UUID, bool) { + uid, ok := userIDFromHeader(r) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return uuid.Nil, false + } + var owner uuid.UUID + switch resourceType { + case "test": + t, err := h.testRepo.Get(r.Context(), resourceID) + if err != nil { + writeRepoError(w, r, err, "get test") + return uuid.Nil, false + } + owner = t.OwnerUserID + case "course": + c, err := h.courseRepo.Get(r.Context(), resourceID) + if err != nil { + writeRepoError(w, r, err, "get course") + return uuid.Nil, false + } + owner = c.OwnerUserID + default: + writeError(w, http.StatusBadRequest, "resource_type must be test|course") + return uuid.Nil, false + } + if owner != uid { + writeError(w, http.StatusForbidden, "only owner can manage access") + return uuid.Nil, false + } + return uid, true +} + +// List — GET /access/{resourceType}/{resourceId}. +func (h *AccessGrantHandler) List(w http.ResponseWriter, r *http.Request) { + resourceType := strings.ToLower(chi.URLParam(r, "resourceType")) + resourceID, err := parseUUID(chi.URLParam(r, "resourceId")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid resource id") + return + } + if _, ok := h.authorizeResourceOwner(w, r, resourceType, resourceID); !ok { + return + } + items, err := h.repo.List(r.Context(), resourceType, resourceID) + if err != nil { + writeRepoError(w, r, err, "list access grants") + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": items}) +} + +// Create — POST /access/{resourceType}/{resourceId} {subject_type, subject_id, can_manage}. +func (h *AccessGrantHandler) Create(w http.ResponseWriter, r *http.Request) { + resourceType := strings.ToLower(chi.URLParam(r, "resourceType")) + resourceID, err := parseUUID(chi.URLParam(r, "resourceId")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid resource id") + return + } + uid, ok := h.authorizeResourceOwner(w, r, resourceType, resourceID) + if !ok { + return + } + var req model.CreateAccessGrantRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid body") + return + } + if req.SubjectType != "user" && req.SubjectType != "role" && + req.SubjectType != "department" && req.SubjectType != "position" && + req.SubjectType != "public" { + writeError(w, http.StatusBadRequest, "subject_type must be user|role|department|position|public") + return + } + g, err := h.repo.Create(r.Context(), resourceType, resourceID, req, uid) + if err != nil { + writeRepoError(w, r, err, "create access grant") + return + } + writeJSON(w, http.StatusCreated, g) +} + +// Delete — DELETE /access/{resourceType}/{resourceId}/grants/{grantId}. +// resourceType/resourceId дублируются в URL'е для удобства проверки прав +// (handler читает их из path-params, не из body). +func (h *AccessGrantHandler) Delete(w http.ResponseWriter, r *http.Request) { + resourceType := strings.ToLower(chi.URLParam(r, "resourceType")) + resourceID, err := parseUUID(chi.URLParam(r, "resourceId")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid resource id") + return + } + grantID, err := parseUUID(chi.URLParam(r, "grantId")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid grant id") + return + } + if _, ok := h.authorizeResourceOwner(w, r, resourceType, resourceID); !ok { + return + } + // Дополнительно проверим, что grant действительно принадлежит этому + // ресурсу — иначе owner ресурса A мог бы удалить грант ресурса B + // просто подсунув grantId. + g, err := h.repo.Get(r.Context(), grantID) + if err != nil { + writeRepoError(w, r, err, "get grant") + return + } + if g.ResourceType != resourceType || g.ResourceID != resourceID { + writeError(w, http.StatusBadRequest, "grant does not belong to this resource") + return + } + if err := h.repo.Delete(r.Context(), grantID); err != nil { + writeRepoError(w, r, err, "delete grant") + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/handler/course.go b/internal/handler/course.go index 5d08261..a832f09 100644 --- a/internal/handler/course.go +++ b/internal/handler/course.go @@ -5,26 +5,24 @@ import ( "strings" "github.com/go-chi/chi/v5" - "github.com/google/uuid" "learning-service/internal/model" "learning-service/internal/repository" ) type CourseHandler struct { - repo *repository.CourseRepository + repo *repository.CourseRepository + accessRepo *repository.AccessGrantRepository } -func NewCourseHandler(repo *repository.CourseRepository) *CourseHandler { - return &CourseHandler{repo: repo} +func NewCourseHandler(repo *repository.CourseRepository, accessRepo *repository.AccessGrantRepository) *CourseHandler { + return &CourseHandler{repo: repo, accessRepo: accessRepo} } -// List — GET /courses?mine=true. Поведение зеркально TestHandler.List: -// -// mine=true → только мои (owner_user_id = X-User-Id); -// без mine → только опубликованные. -// -// В будущей итерации access_grants добавят visibleIDs-фильтр. +// List — GET /courses?mine=true. Зеркально TestHandler.List: +// mine=true → только мои; +// без mine → published + видимые через access_grants. +// Семантика union'а: published все + дозалив unpublished, выданных через grant. func (h *CourseHandler) List(w http.ResponseWriter, r *http.Request) { uid, ok := userIDFromHeader(r) if !ok { @@ -32,15 +30,43 @@ func (h *CourseHandler) List(w http.ResponseWriter, r *http.Request) { return } mine := r.URL.Query().Get("mine") == "true" - var ownerFilter *uuid.UUID if mine { - ownerFilter = &uid + items, err := h.repo.List(r.Context(), &uid, false, nil) + if err != nil { + writeRepoError(w, r, err, "list courses") + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": items}) + return } - items, err := h.repo.List(r.Context(), ownerFilter, !mine, nil) + viewer := viewerContextFromHeaders(r, uid) + grantIDs, err := h.accessRepo.ResolveVisibleResourceIDs(r.Context(), "course", viewer) + if err != nil { + writeRepoError(w, r, err, "resolve access") + return + } + items, err := h.repo.List(r.Context(), nil, true, nil) if err != nil { writeRepoError(w, r, err, "list courses") return } + if len(grantIDs) > 0 { + extra, err := h.repo.List(r.Context(), nil, false, grantIDs) + if err != nil { + writeRepoError(w, r, err, "list granted courses") + return + } + seen := map[string]struct{}{} + for _, c := range items { + seen[c.ID.String()] = struct{}{} + } + for _, c := range extra { + if _, ok := seen[c.ID.String()]; ok { + continue + } + items = append(items, c) + } + } writeJSON(w, http.StatusOK, map[string]any{"items": items}) } diff --git a/internal/handler/helpers.go b/internal/handler/helpers.go index cbbc0dc..8090c80 100644 --- a/internal/handler/helpers.go +++ b/internal/handler/helpers.go @@ -5,6 +5,7 @@ import ( "errors" "log/slog" "net/http" + "strings" "github.com/google/uuid" @@ -61,3 +62,34 @@ func userIDFromHeader(r *http.Request) (uuid.UUID, bool) { } return id, true } + +// viewerContextFromHeaders — собирает ViewerContext из portal-gateway- +// прокинутых headers'ов. Пустые поля = соответствующие access_grants +// не сматчатся (что корректно: «у меня нет роли X» → ресурсы выданные +// роли X мне не видны). +func viewerContextFromHeaders(r *http.Request, uid uuid.UUID) repository.ViewerContext { + ctx := repository.ViewerContext{UserID: uid} + // X-User-Roles: CSV ролевых UUID'ов. + if v := r.Header.Get("X-User-Roles"); v != "" { + for _, raw := range strings.Split(v, ",") { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + if id, err := uuid.Parse(raw); err == nil { + ctx.RoleIDs = append(ctx.RoleIDs, id) + } + } + } + if v := r.Header.Get("X-User-Department-Id"); v != "" { + if id, err := uuid.Parse(strings.TrimSpace(v)); err == nil { + ctx.DepartmentID = &id + } + } + if v := r.Header.Get("X-User-Position-Id"); v != "" { + if id, err := uuid.Parse(strings.TrimSpace(v)); err == nil { + ctx.PositionID = &id + } + } + return ctx +} diff --git a/internal/handler/test.go b/internal/handler/test.go index 8bd1ca8..ce98f2d 100644 --- a/internal/handler/test.go +++ b/internal/handler/test.go @@ -12,20 +12,29 @@ import ( ) type TestHandler struct { - repo *repository.TestRepository + repo *repository.TestRepository + accessRepo *repository.AccessGrantRepository } -func NewTestHandler(repo *repository.TestRepository) *TestHandler { - return &TestHandler{repo: repo} +func NewTestHandler(repo *repository.TestRepository, accessRepo *repository.AccessGrantRepository) *TestHandler { + return &TestHandler{repo: repo, accessRepo: accessRepo} } // List — GET /tests. Параметры: // ?mine=true — только мои (owner_user_id = X-User-Id); -// без mine — published тесты (для прохождения). +// без mine — published тесты, ВИДИМЫЕ юзеру через access_grants. // -// MVP: ещё не подключён access_grants-фильтр; до этого момента «published» -// = «всем видно». Следующая итерация: handler через AccessRepository -// получит visibleIDs и передаст в repo.List. +// Видимость: +// - owner всегда видит свои (мимо access_grants); +// - не-owner видит published + (resolvedVisibleIDs ИЛИ public-grant). +// MVP: published без явных grants считается «видимым всем» — этот режим +// сохранён как fallback, чтобы не сломать существующие тесты. Когда фронт +// начнёт массово выдавать гранты на каждый тест, перейдём на строгий +// режим (если есть хотя бы один не-public grant, видны только grant'ы). +// +// На now: visibleIDs объединяет (own published) ∪ (через access_grants). +// Если у юзера 0 grant'ов И тест опубликован — он его всё равно видит +// (publicness == default). func (h *TestHandler) List(w http.ResponseWriter, r *http.Request) { uid, ok := userIDFromHeader(r) if !ok { @@ -33,15 +42,53 @@ func (h *TestHandler) List(w http.ResponseWriter, r *http.Request) { return } mine := r.URL.Query().Get("mine") == "true" - var ownerFilter *uuid.UUID if mine { - ownerFilter = &uid + tests, err := h.repo.List(r.Context(), &uid, false, nil) + if err != nil { + writeRepoError(w, r, err, "list tests") + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": tests}) + return } - tests, err := h.repo.List(r.Context(), ownerFilter, !mine, nil) + // Не-owner: published + access_grants. Передаём visibleIDs=nil (без + // фильтра по id) если access_grants пуст — тогда видны все published. + // Если есть хоть один grant — берём union (id IN grants) OR + // (is_published AND owner = me); но репо API сейчас принимает один + // visibleIDs-набор. Расширим в next iteration; в MVP даём оба пути: + // просто все published, плюс отдельно — все из grant'ов даже если + // они не published. + viewer := viewerContextFromHeaders(r, uid) + grantIDs, err := h.accessRepo.ResolveVisibleResourceIDs(r.Context(), "test", viewer) + if err != nil { + writeRepoError(w, r, err, "resolve access") + return + } + tests, err := h.repo.List(r.Context(), nil, true, nil) if err != nil { writeRepoError(w, r, err, "list tests") return } + // Дозаливаем тесты которые выданы через grants но не опубликованы. + if len(grantIDs) > 0 { + extra, err := h.repo.List(r.Context(), nil, false, grantIDs) + if err != nil { + writeRepoError(w, r, err, "list granted tests") + return + } + seen := map[string]struct{}{} + for _, t := range tests { + seen[t.ID.String()] = struct{}{} + } + for _, t := range extra { + if _, ok := seen[t.ID.String()]; ok { + continue + } + // Black-box: уже опубликованные через published-ветку не дублируются; + // добавляем только новые (выданные через grant, но НЕ published). + tests = append(tests, t) + } + } writeJSON(w, http.StatusOK, map[string]any{"items": tests}) } diff --git a/internal/repository/access_grant.go b/internal/repository/access_grant.go new file mode 100644 index 0000000..003841a --- /dev/null +++ b/internal/repository/access_grant.go @@ -0,0 +1,180 @@ +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() +}