generated from coulomb/repo-seed
Implement local registry store
This commit is contained in:
99
examples/caring/registry_snapshot.json
Normal file
99
examples/caring/registry_snapshot.json
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
{
|
||||||
|
"systems": [
|
||||||
|
{
|
||||||
|
"id": "markitect-tool",
|
||||||
|
"name": "Markitect Tool",
|
||||||
|
"resource_types": [
|
||||||
|
{
|
||||||
|
"name": "document",
|
||||||
|
"scope_level": "Resource",
|
||||||
|
"planes": ["Data"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"name": "read",
|
||||||
|
"capabilities": ["View"],
|
||||||
|
"planes": ["Data"],
|
||||||
|
"exposure_modes": ["Masked", "Plaintext"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"caring_profiles": ["caring-0.4.0-rc2"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"resource_manifests": [
|
||||||
|
{
|
||||||
|
"id": "markitect-example-knowledge-base",
|
||||||
|
"system": "markitect-tool",
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"id": "document:internal-note",
|
||||||
|
"type": "document",
|
||||||
|
"parent": "knowledge-base:markitect-example",
|
||||||
|
"labels": ["internal"],
|
||||||
|
"trust_zone": "internal",
|
||||||
|
"owner": "team:platform"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": ["read", "query", "search", "package", "export"],
|
||||||
|
"caring_profile": "caring-0.4.0-rc2",
|
||||||
|
"metadata": {
|
||||||
|
"flex_auth_contract": "resource-registration-v0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tenants": [
|
||||||
|
{
|
||||||
|
"id": "tenant:alpha",
|
||||||
|
"name": "Tenant Alpha"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"subjects": [
|
||||||
|
{
|
||||||
|
"id": "user:alice",
|
||||||
|
"type": "Human",
|
||||||
|
"display_name": "Alice Example",
|
||||||
|
"organization_relation": "Customer",
|
||||||
|
"roles": ["Doer"],
|
||||||
|
"groups": ["group:platform-architecture"],
|
||||||
|
"tenant": "tenant:alpha"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"id": "group:platform-architecture",
|
||||||
|
"display_name": "Platform Architecture",
|
||||||
|
"members": ["user:alice"],
|
||||||
|
"tenant": "tenant:alpha"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"relationships": [
|
||||||
|
{
|
||||||
|
"id": "rel:alice-reader-internal-note",
|
||||||
|
"system": "markitect-tool",
|
||||||
|
"subject": "group:platform-architecture",
|
||||||
|
"relation": "reader",
|
||||||
|
"object": "document:internal-note",
|
||||||
|
"tenant": "tenant:alpha",
|
||||||
|
"conditions": ["Logged"],
|
||||||
|
"caring": {
|
||||||
|
"id": "descriptor:tenant-alpha-document-reader",
|
||||||
|
"profile": "caring-0.4.0-rc2",
|
||||||
|
"subject_type": "Group",
|
||||||
|
"organization_relation": "Customer",
|
||||||
|
"canonical_role": "Doer",
|
||||||
|
"scope": {
|
||||||
|
"level": "Resource",
|
||||||
|
"id": "document:internal-note",
|
||||||
|
"tenant": "tenant:alpha",
|
||||||
|
"resource": "document:internal-note"
|
||||||
|
},
|
||||||
|
"planes": ["Data"],
|
||||||
|
"capabilities": ["View"],
|
||||||
|
"exposure_modes": ["Masked", "Plaintext"],
|
||||||
|
"conditions": ["Logged"],
|
||||||
|
"restrictions": ["ExportBlocked"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
314
internal/registry/store.go
Normal file
314
internal/registry/store.go
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/netkingdom/flex-auth/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store is a deterministic local registry for standalone development and tests.
|
||||||
|
type Store struct {
|
||||||
|
systems map[string]api.ProtectedSystemManifest
|
||||||
|
resourceManifests map[string]api.ResourceManifest
|
||||||
|
resources map[string]api.Resource
|
||||||
|
subjects map[string]api.Subject
|
||||||
|
groups map[string]api.Group
|
||||||
|
teams map[string]api.Team
|
||||||
|
tenants map[string]api.Tenant
|
||||||
|
relationships map[string]api.RelationshipFact
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot is the file-backed representation of a Store.
|
||||||
|
type Snapshot struct {
|
||||||
|
Systems []api.ProtectedSystemManifest `json:"systems,omitempty"`
|
||||||
|
ResourceManifests []api.ResourceManifest `json:"resource_manifests,omitempty"`
|
||||||
|
Subjects []api.Subject `json:"subjects,omitempty"`
|
||||||
|
Groups []api.Group `json:"groups,omitempty"`
|
||||||
|
Teams []api.Team `json:"teams,omitempty"`
|
||||||
|
Tenants []api.Tenant `json:"tenants,omitempty"`
|
||||||
|
Relationships []api.RelationshipFact `json:"relationships,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStore returns an empty local registry store.
|
||||||
|
func NewStore() *Store {
|
||||||
|
return &Store{
|
||||||
|
systems: make(map[string]api.ProtectedSystemManifest),
|
||||||
|
resourceManifests: make(map[string]api.ResourceManifest),
|
||||||
|
resources: make(map[string]api.Resource),
|
||||||
|
subjects: make(map[string]api.Subject),
|
||||||
|
groups: make(map[string]api.Group),
|
||||||
|
teams: make(map[string]api.Team),
|
||||||
|
tenants: make(map[string]api.Tenant),
|
||||||
|
relationships: make(map[string]api.RelationshipFact),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadFile loads a Store from a JSON snapshot file.
|
||||||
|
func LoadFile(path string) (*Store, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read registry snapshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot Snapshot
|
||||||
|
if err := json.Unmarshal(data, &snapshot); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal registry snapshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
store := NewStore()
|
||||||
|
if err := store.ApplySnapshot(snapshot); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return store, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveFile writes the store as deterministic pretty-printed JSON.
|
||||||
|
func (s *Store) SaveFile(path string) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create registry snapshot directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(s.Snapshot(), "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal registry snapshot: %w", err)
|
||||||
|
}
|
||||||
|
data = append(data, '\n')
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write registry snapshot: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplySnapshot imports every record from a snapshot into the store.
|
||||||
|
func (s *Store) ApplySnapshot(snapshot Snapshot) error {
|
||||||
|
for _, system := range snapshot.Systems {
|
||||||
|
if err := s.PutProtectedSystem(system); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, manifest := range snapshot.ResourceManifests {
|
||||||
|
if err := s.ImportResourceManifest(manifest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tenant := range snapshot.Tenants {
|
||||||
|
if err := s.PutTenant(tenant); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, subject := range snapshot.Subjects {
|
||||||
|
if err := s.PutSubject(subject); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, group := range snapshot.Groups {
|
||||||
|
if err := s.PutGroup(group); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, team := range snapshot.Teams {
|
||||||
|
if err := s.PutTeam(team); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, relationship := range snapshot.Relationships {
|
||||||
|
if err := s.PutRelationship(relationship); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot returns a deterministic store snapshot.
|
||||||
|
func (s *Store) Snapshot() Snapshot {
|
||||||
|
return Snapshot{
|
||||||
|
Systems: sortedValues(s.systems),
|
||||||
|
ResourceManifests: sortedValues(s.resourceManifests),
|
||||||
|
Subjects: sortedValues(s.subjects),
|
||||||
|
Groups: sortedValues(s.groups),
|
||||||
|
Teams: sortedValues(s.teams),
|
||||||
|
Tenants: sortedValues(s.tenants),
|
||||||
|
Relationships: sortedValues(s.relationships),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutProtectedSystem stores or replaces a protected system manifest.
|
||||||
|
func (s *Store) PutProtectedSystem(system api.ProtectedSystemManifest) error {
|
||||||
|
if system.ID == "" {
|
||||||
|
return fmt.Errorf("protected system id is required")
|
||||||
|
}
|
||||||
|
s.systems[system.ID] = system
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportResourceManifest stores a resource manifest and indexes its resources.
|
||||||
|
func (s *Store) ImportResourceManifest(manifest api.ResourceManifest) error {
|
||||||
|
if manifest.ID == "" {
|
||||||
|
return fmt.Errorf("resource manifest id is required")
|
||||||
|
}
|
||||||
|
if manifest.System == "" {
|
||||||
|
return fmt.Errorf("resource manifest system is required")
|
||||||
|
}
|
||||||
|
for _, resource := range manifest.Resources {
|
||||||
|
if resource.ID == "" || resource.Type == "" {
|
||||||
|
return fmt.Errorf("resource manifest %q contains resource with missing id or type", manifest.ID)
|
||||||
|
}
|
||||||
|
s.resources[resourceKey(manifest.System, resource.ID)] = resource
|
||||||
|
}
|
||||||
|
s.resourceManifests[manifest.ID] = manifest
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportSubjectManifest stores subjects, groups, teams, and tenants.
|
||||||
|
func (s *Store) ImportSubjectManifest(manifest api.SubjectManifest) error {
|
||||||
|
if manifest.ID == "" {
|
||||||
|
return fmt.Errorf("subject manifest id is required")
|
||||||
|
}
|
||||||
|
for _, tenant := range manifest.Tenants {
|
||||||
|
if err := s.PutTenant(tenant); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, subject := range manifest.Subjects {
|
||||||
|
if err := s.PutSubject(subject); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, group := range manifest.Groups {
|
||||||
|
if err := s.PutGroup(group); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, team := range manifest.Teams {
|
||||||
|
if err := s.PutTeam(team); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutSubject stores or replaces a subject.
|
||||||
|
func (s *Store) PutSubject(subject api.Subject) error {
|
||||||
|
if subject.ID == "" {
|
||||||
|
return fmt.Errorf("subject id is required")
|
||||||
|
}
|
||||||
|
s.subjects[subject.ID] = subject
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutGroup stores or replaces a group.
|
||||||
|
func (s *Store) PutGroup(group api.Group) error {
|
||||||
|
if group.ID == "" {
|
||||||
|
return fmt.Errorf("group id is required")
|
||||||
|
}
|
||||||
|
s.groups[group.ID] = group
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutTeam stores or replaces a team.
|
||||||
|
func (s *Store) PutTeam(team api.Team) error {
|
||||||
|
if team.ID == "" {
|
||||||
|
return fmt.Errorf("team id is required")
|
||||||
|
}
|
||||||
|
s.teams[team.ID] = team
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutTenant stores or replaces a tenant.
|
||||||
|
func (s *Store) PutTenant(tenant api.Tenant) error {
|
||||||
|
if tenant.ID == "" {
|
||||||
|
return fmt.Errorf("tenant id is required")
|
||||||
|
}
|
||||||
|
s.tenants[tenant.ID] = tenant
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutRelationship stores or replaces a relationship fact.
|
||||||
|
func (s *Store) PutRelationship(relationship api.RelationshipFact) error {
|
||||||
|
if relationship.ID == "" {
|
||||||
|
return fmt.Errorf("relationship id is required")
|
||||||
|
}
|
||||||
|
if relationship.Subject == "" || relationship.Relation == "" || relationship.Object == "" {
|
||||||
|
return fmt.Errorf("relationship %q requires subject, relation, and object", relationship.ID)
|
||||||
|
}
|
||||||
|
s.relationships[relationship.ID] = relationship
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resource looks up a resource by protected system and resource id.
|
||||||
|
func (s *Store) Resource(system, id string) (api.Resource, bool) {
|
||||||
|
resource, ok := s.resources[resourceKey(system, id)]
|
||||||
|
return resource, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subject looks up a subject by id.
|
||||||
|
func (s *Store) Subject(id string) (api.Subject, bool) {
|
||||||
|
subject, ok := s.subjects[id]
|
||||||
|
return subject, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group looks up a group by id.
|
||||||
|
func (s *Store) Group(id string) (api.Group, bool) {
|
||||||
|
group, ok := s.groups[id]
|
||||||
|
return group, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Team looks up a team by id.
|
||||||
|
func (s *Store) Team(id string) (api.Team, bool) {
|
||||||
|
team, ok := s.teams[id]
|
||||||
|
return team, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenant looks up a tenant by id.
|
||||||
|
func (s *Store) Tenant(id string) (api.Tenant, bool) {
|
||||||
|
tenant, ok := s.tenants[id]
|
||||||
|
return tenant, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelationshipsForSubject returns deterministic relationship facts for a subject.
|
||||||
|
func (s *Store) RelationshipsForSubject(subject string) []api.RelationshipFact {
|
||||||
|
return s.relationshipsWhere(func(fact api.RelationshipFact) bool {
|
||||||
|
return fact.Subject == subject
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelationshipsForObject returns deterministic relationship facts for an object.
|
||||||
|
func (s *Store) RelationshipsForObject(object string) []api.RelationshipFact {
|
||||||
|
return s.relationshipsWhere(func(fact api.RelationshipFact) bool {
|
||||||
|
return fact.Object == object
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) relationshipsWhere(match func(api.RelationshipFact) bool) []api.RelationshipFact {
|
||||||
|
var out []api.RelationshipFact
|
||||||
|
for _, fact := range s.relationships {
|
||||||
|
if match(fact) {
|
||||||
|
out = append(out, fact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func resourceKey(system, id string) string {
|
||||||
|
return system + "\x00" + id
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedValues[T any](items map[string]T) []T {
|
||||||
|
keys := make([]string, 0, len(items))
|
||||||
|
for key := range items {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
out := make([]T, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
out = append(out, items[key])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
94
internal/registry/store_test.go
Normal file
94
internal/registry/store_test.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package registry_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netkingdom/flex-auth/internal/registry"
|
||||||
|
"github.com/netkingdom/flex-auth/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStoreImportsManifests(t *testing.T) {
|
||||||
|
store := registry.NewStore()
|
||||||
|
|
||||||
|
var subjects api.SubjectManifest
|
||||||
|
loadYAML(t, filepath.Join("..", "..", "examples", "caring", "subject_manifest.yaml"), &subjects)
|
||||||
|
if err := store.ImportSubjectManifest(subjects); err != nil {
|
||||||
|
t.Fatalf("ImportSubjectManifest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var relationship api.RelationshipFact
|
||||||
|
loadYAML(t, filepath.Join("..", "..", "examples", "caring", "relationship_fact.yaml"), &relationship)
|
||||||
|
if err := store.PutRelationship(relationship); err != nil {
|
||||||
|
t.Fatalf("PutRelationship: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
subject, ok := store.Subject("user:alice")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("subject user:alice not found")
|
||||||
|
}
|
||||||
|
if subject.Tenant != "tenant:alpha" {
|
||||||
|
t.Errorf("subject.Tenant = %q; want tenant:alpha", subject.Tenant)
|
||||||
|
}
|
||||||
|
|
||||||
|
relations := store.RelationshipsForObject("document:internal-note")
|
||||||
|
if len(relations) != 1 || relations[0].Subject != "group:platform-architecture" {
|
||||||
|
t.Fatalf("RelationshipsForObject = %+v; want group reader relation", relations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreLoadsAndSavesDeterministicSnapshot(t *testing.T) {
|
||||||
|
snapshotPath := filepath.Join("..", "..", "examples", "caring", "registry_snapshot.json")
|
||||||
|
store, err := registry.LoadFile(snapshotPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resource, ok := store.Resource("markitect-tool", "document:internal-note")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("resource document:internal-note not found")
|
||||||
|
}
|
||||||
|
if resource.TrustZone != "internal" {
|
||||||
|
t.Errorf("resource.TrustZone = %q; want internal", resource.TrustZone)
|
||||||
|
}
|
||||||
|
|
||||||
|
outPath := filepath.Join(t.TempDir(), "snapshot.json")
|
||||||
|
if err := store.SaveFile(outPath); err != nil {
|
||||||
|
t.Fatalf("SaveFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reloaded, err := registry.LoadFile(outPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload saved snapshot: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := mustJSON(t, reloaded.Snapshot())
|
||||||
|
want := mustJSON(t, store.Snapshot())
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("saved snapshot changed after reload\nwant: %s\ngot: %s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreRejectsInvalidRecords(t *testing.T) {
|
||||||
|
store := registry.NewStore()
|
||||||
|
if err := store.PutSubject(api.Subject{}); err == nil {
|
||||||
|
t.Fatal("PutSubject accepted missing id")
|
||||||
|
}
|
||||||
|
if err := store.ImportResourceManifest(api.ResourceManifest{ID: "m1"}); err == nil {
|
||||||
|
t.Fatal("ImportResourceManifest accepted missing system")
|
||||||
|
}
|
||||||
|
if err := store.PutRelationship(api.RelationshipFact{ID: "r1"}); err == nil {
|
||||||
|
t.Fatal("PutRelationship accepted missing subject/relation/object")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustJSON(t *testing.T, value any) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
data, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal json: %v", err)
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
@@ -101,7 +101,7 @@ Output: docs, JSON Schema files in `schemas/`, runnable examples in
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: FLEX-WP-0002-T002
|
id: FLEX-WP-0002-T002
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "d8045124-f0ae-495d-87b5-24fd9528ef93"
|
state_hub_task_id: "d8045124-f0ae-495d-87b5-24fd9528ef93"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user