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>
96 lines
2.9 KiB
Go
96 lines
2.9 KiB
Go
package handler
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"log/slog"
|
||
"net/http"
|
||
"strings"
|
||
|
||
"github.com/google/uuid"
|
||
|
||
"learning-service/internal/repository"
|
||
)
|
||
|
||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(status)
|
||
_ = json.NewEncoder(w).Encode(v)
|
||
}
|
||
|
||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||
writeJSON(w, status, map[string]any{"error": msg})
|
||
}
|
||
|
||
// writeRepoError — маппит repository.Err* на HTTP-коды. Для остальных
|
||
// логируем и отдаём 500 без деталей (наружу не светим внутренние ошибки).
|
||
func writeRepoError(w http.ResponseWriter, r *http.Request, err error, action string) {
|
||
switch {
|
||
case errors.Is(err, repository.ErrNotFound):
|
||
writeError(w, http.StatusNotFound, "not found")
|
||
case errors.Is(err, repository.ErrForbidden):
|
||
writeError(w, http.StatusForbidden, "forbidden")
|
||
default:
|
||
slog.Error("repo error", "action", action, "error", err, "path", r.URL.Path)
|
||
writeError(w, http.StatusInternalServerError, "internal error")
|
||
}
|
||
}
|
||
|
||
func decodeJSON(r *http.Request, v any) error {
|
||
dec := json.NewDecoder(r.Body)
|
||
dec.DisallowUnknownFields()
|
||
return dec.Decode(v)
|
||
}
|
||
|
||
func parseUUID(s string) (uuid.UUID, error) {
|
||
if s == "" {
|
||
return uuid.Nil, errors.New("empty uuid")
|
||
}
|
||
return uuid.Parse(s)
|
||
}
|
||
|
||
// userIDFromHeader — portal-gateway проставляет X-User-Id после валидации JWT.
|
||
// Все НЕ-internal handler'ы читают отсюда; пустой = unauthorized.
|
||
func userIDFromHeader(r *http.Request) (uuid.UUID, bool) {
|
||
v := r.Header.Get("X-User-Id")
|
||
if v == "" {
|
||
return uuid.Nil, false
|
||
}
|
||
id, err := uuid.Parse(v)
|
||
if err != nil {
|
||
return uuid.Nil, false
|
||
}
|
||
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
|
||
}
|