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 } 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 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() req, _ := http.NewRequestWithContext(ctx, "GET", u.String(), nil) resp, err := s.client.Do(req) if err != nil { return false, err } defer resp.Body.Close() if resp.StatusCode == http.StatusForbidden { return false, nil } if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return false, fmt.Errorf("keto returned status %d: %s", resp.StatusCode, string(body)) } var res checkResponse if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { return false, err } return res.Allowed, nil } 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]interface{}{ "namespace": namespace, "object": object, "relation": relation, "subject_id": subject, } body, _ := json.Marshal(payload) // Exponential Backoff Retry Logic var lastErr error maxRetries := 3 backoff := 100 * time.Millisecond for i := 0; i < maxRetries; i++ { 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.Info("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 } time.Sleep(backoff) backoff *= 2 } slog.Error("Keto create relation failed after retries", "error", lastErr) return lastErr } func (s *ketoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error { u, _ := url.Parse(fmt.Sprintf("%s/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() req, _ := http.NewRequestWithContext(ctx, "DELETE", u.String(), nil) resp, err := s.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { resBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("keto returned status %d: %s", resp.StatusCode, string(resBody)) } slog.Info("Keto relation deleted", "namespace", namespace, "object", object, "relation", relation, "subject", subject) return nil }