forked from baron/baron-sso
- Added support for fixed UUIDs during bulk registration (Search-first + ExternalID mapping) - Implemented idempotency and visibility restoration for soft-deleted users - Enhanced bulk upload UI to show 'New/Updated/Unchanged' status and modified fields - Added logic to reclaim identifiers (login_id) from colliding records - Added frontend E2E and backend unit tests for UUID integrity and conflict handling - Fixed i18n, formatting, and mock tests to satisfy code-check - Applied 'go fix' for 'omitzero' tags and general Go standards
268 lines
7.1 KiB
Go
268 lines
7.1 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"time"
|
|
)
|
|
|
|
type KetoService interface {
|
|
CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error)
|
|
CreateRelation(ctx context.Context, namespace, object, relation, subject string) error
|
|
DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error
|
|
ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]RelationTuple, error)
|
|
ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error)
|
|
}
|
|
|
|
type ketoService struct {
|
|
readURL string
|
|
writeURL string
|
|
client *http.Client
|
|
}
|
|
|
|
func NewKetoService() KetoService {
|
|
readURL := os.Getenv("KETO_READ_URL")
|
|
if readURL == "" {
|
|
readURL = "http://keto:4466"
|
|
}
|
|
writeURL := os.Getenv("KETO_WRITE_URL")
|
|
if writeURL == "" {
|
|
writeURL = "http://keto:4467"
|
|
}
|
|
|
|
return &ketoService{
|
|
readURL: readURL,
|
|
writeURL: writeURL,
|
|
client: &http.Client{},
|
|
}
|
|
}
|
|
|
|
type RelationTuple struct {
|
|
Namespace string `json:"namespace"`
|
|
Object string `json:"object"`
|
|
Relation string `json:"relation"`
|
|
SubjectID string `json:"subject_id"`
|
|
}
|
|
|
|
type relationTuplesResponse struct {
|
|
RelationTuples []RelationTuple `json:"relation_tuples"`
|
|
NextPageToken string `json:"next_page_token"`
|
|
}
|
|
|
|
func (s *ketoService) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]RelationTuple, error) {
|
|
u, _ := url.Parse(fmt.Sprintf("%s/relation-tuples", s.readURL))
|
|
q := u.Query()
|
|
if namespace != "" {
|
|
q.Set("namespace", namespace)
|
|
}
|
|
if object != "" {
|
|
q.Set("object", object)
|
|
}
|
|
if relation != "" {
|
|
q.Set("relation", relation)
|
|
}
|
|
if subject != "" {
|
|
q.Set("subject_id", subject)
|
|
}
|
|
u.RawQuery = q.Encode()
|
|
|
|
req, _ := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("keto returned status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var res relationTuplesResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return res.RelationTuples, nil
|
|
}
|
|
|
|
type checkResponse struct {
|
|
Allowed bool `json:"allowed"`
|
|
}
|
|
|
|
func (s *ketoService) CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error) {
|
|
u, _ := url.Parse(fmt.Sprintf("%s/relation-tuples/check", s.readURL))
|
|
q := u.Query()
|
|
q.Set("namespace", namespace)
|
|
q.Set("object", object)
|
|
q.Set("relation", relation)
|
|
q.Set("subject_id", subject)
|
|
u.RawQuery = q.Encode()
|
|
|
|
var lastErr error
|
|
maxRetries := 5
|
|
backoff := 200 * time.Millisecond
|
|
|
|
for i := range maxRetries {
|
|
req, _ := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
|
|
resp, err := s.client.Do(req)
|
|
if err == nil {
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode == http.StatusOK {
|
|
var res checkResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
|
return false, err
|
|
}
|
|
return res.Allowed, nil
|
|
}
|
|
if resp.StatusCode == http.StatusForbidden {
|
|
return false, nil
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
lastErr = fmt.Errorf("keto returned status %d: %s", resp.StatusCode, string(body))
|
|
} else {
|
|
lastErr = err
|
|
}
|
|
|
|
if i < maxRetries-1 {
|
|
slog.Debug("Retrying Keto CheckPermission...", "attempt", i+1, "error", lastErr)
|
|
time.Sleep(backoff)
|
|
backoff *= 2
|
|
}
|
|
}
|
|
|
|
return false, lastErr
|
|
}
|
|
|
|
func (s *ketoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
|
u := fmt.Sprintf("%s/admin/relation-tuples", s.writeURL)
|
|
payload := map[string]any{
|
|
"namespace": namespace,
|
|
"object": object,
|
|
"relation": relation,
|
|
"subject_id": subject,
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
|
|
// Exponential Backoff Retry Logic
|
|
var lastErr error
|
|
maxRetries := 5
|
|
backoff := 200 * time.Millisecond
|
|
|
|
for i := range maxRetries {
|
|
req, _ := http.NewRequestWithContext(ctx, "PUT", u, bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := s.client.Do(req)
|
|
if err == nil {
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK {
|
|
slog.Debug("Keto relation created", "namespace", namespace, "object", object, "relation", relation, "subject", subject)
|
|
return nil
|
|
}
|
|
resBody, _ := io.ReadAll(resp.Body)
|
|
lastErr = fmt.Errorf("keto returned status %d: %s", resp.StatusCode, string(resBody))
|
|
} else {
|
|
lastErr = err
|
|
}
|
|
|
|
if i < maxRetries-1 {
|
|
slog.Debug("Retrying Keto CreateRelation...", "attempt", i+1, "error", lastErr)
|
|
time.Sleep(backoff)
|
|
backoff *= 2
|
|
}
|
|
}
|
|
|
|
slog.Error("Keto create relation failed after retries", "error", lastErr, "namespace", namespace, "object", object, "relation", relation, "subject", subject)
|
|
return lastErr
|
|
}
|
|
|
|
func (s *ketoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
|
u, _ := url.Parse(fmt.Sprintf("%s/admin/relation-tuples", s.writeURL))
|
|
q := u.Query()
|
|
q.Set("namespace", namespace)
|
|
q.Set("object", object)
|
|
q.Set("relation", relation)
|
|
q.Set("subject_id", subject)
|
|
u.RawQuery = q.Encode()
|
|
|
|
var lastErr error
|
|
maxRetries := 5
|
|
backoff := 200 * time.Millisecond
|
|
|
|
for i := range maxRetries {
|
|
req, _ := http.NewRequestWithContext(ctx, "DELETE", u.String(), nil)
|
|
resp, err := s.client.Do(req)
|
|
if err == nil {
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK {
|
|
slog.Debug("Keto relation deleted", "namespace", namespace, "object", object, "relation", relation, "subject", subject)
|
|
return nil
|
|
}
|
|
resBody, _ := io.ReadAll(resp.Body)
|
|
lastErr = fmt.Errorf("keto returned status %d: %s", resp.StatusCode, string(resBody))
|
|
} else {
|
|
lastErr = err
|
|
}
|
|
|
|
if i < maxRetries-1 {
|
|
slog.Debug("Retrying Keto DeleteRelation...", "attempt", i+1, "error", lastErr)
|
|
time.Sleep(backoff)
|
|
backoff *= 2
|
|
}
|
|
}
|
|
|
|
slog.Error("Keto delete relation failed after retries", "error", lastErr)
|
|
return lastErr
|
|
}
|
|
|
|
func (s *ketoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
|
|
u, _ := url.Parse(fmt.Sprintf("%s/relation-tuples", s.readURL))
|
|
q := u.Query()
|
|
if namespace != "" {
|
|
q.Set("namespace", namespace)
|
|
}
|
|
if relation != "" {
|
|
q.Set("relation", relation)
|
|
}
|
|
if subject != "" {
|
|
q.Set("subject_id", subject)
|
|
}
|
|
u.RawQuery = q.Encode()
|
|
|
|
req, _ := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("keto returned status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var res relationTuplesResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
objects := make([]string, 0, len(res.RelationTuples))
|
|
seen := make(map[string]bool)
|
|
for _, rt := range res.RelationTuples {
|
|
if !seen[rt.Object] {
|
|
objects = append(objects, rt.Object)
|
|
seen[rt.Object] = true
|
|
}
|
|
}
|
|
|
|
return objects, nil
|
|
}
|