Some checks failed
CI / Run lints and checks (push) Failing after 47s
CI / Run tests (push) Failing after 1s
CI / Run race tests (push) Failing after 5s
CI / Build CLI docs (push) Has been skipped
CI / Run buf actions (push) Failing after 5s
CodeQL / Analyze (go) (push) Failing after 3s
CodeQL / Analyze (javascript) (push) Failing after 2s
Docker Image Scanners / scanners (push) Failing after 3s
Format / format (push) Failing after 3s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Draft newsletter (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
101 lines
2.7 KiB
Go
101 lines
2.7 KiB
Go
// Copyright © 2026 Ory Corp
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
|
|
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
|
|
)
|
|
|
|
const batchSize = 100
|
|
|
|
// subjectIDToNamespacedSubject converts a prefixed subject ID to a namespace and object.
|
|
// The prefix encodes the type: "user_" → User namespace, "apikey_" → ApiKey namespace.
|
|
func subjectIDToNamespacedSubject(id string) (namespace, object string) {
|
|
switch {
|
|
case strings.HasPrefix(id, "user_"):
|
|
return "User", strings.TrimPrefix(id, "user_")
|
|
case strings.HasPrefix(id, "apikey_"):
|
|
return "ApiKey", strings.TrimPrefix(id, "apikey_")
|
|
default:
|
|
panic("unknown subject ID prefix: " + id)
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
rc, err := grpc.NewClient("127.0.0.1:4466", grpc.WithTransportCredentials(insecure.NewCredentials()))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
wc, err := grpc.NewClient("127.0.0.1:4467", grpc.WithTransportCredentials(insecure.NewCredentials()))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
readClient := rts.NewReadServiceClient(rc)
|
|
writeClient := rts.NewWriteServiceClient(wc)
|
|
ctx := context.Background()
|
|
|
|
// Step 1: Paginate all File#viewer tuples and collect those with a SubjectID.
|
|
// In production, persist these to a file, SQLite, or a database before writing back,
|
|
// so the migration can be resumed if interrupted.
|
|
var toMigrate []*rts.RelationTuple
|
|
var pageToken string
|
|
for {
|
|
resp, err := readClient.ListRelationTuples(ctx, &rts.ListRelationTuplesRequest{
|
|
RelationQuery: &rts.RelationQuery{
|
|
Namespace: new("File"),
|
|
Relation: new("viewer"),
|
|
},
|
|
PageToken: pageToken,
|
|
PageSize: batchSize,
|
|
})
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
for _, t := range resp.RelationTuples {
|
|
if t.Subject.GetId() != "" {
|
|
toMigrate = append(toMigrate, t)
|
|
}
|
|
}
|
|
if resp.NextPageToken == "" {
|
|
break
|
|
}
|
|
pageToken = resp.NextPageToken
|
|
}
|
|
// Step 2: Write SubjectSet counterparts in batches.
|
|
for i := 0; i < len(toMigrate); i += batchSize {
|
|
end := min(i+batchSize, len(toMigrate))
|
|
batch := toMigrate[i:end]
|
|
|
|
deltas := make([]*rts.RelationTupleDelta, len(batch))
|
|
for j, t := range batch {
|
|
ns, obj := subjectIDToNamespacedSubject(t.Subject.GetId())
|
|
deltas[j] = &rts.RelationTupleDelta{
|
|
Action: rts.RelationTupleDelta_ACTION_INSERT,
|
|
RelationTuple: &rts.RelationTuple{
|
|
Namespace: t.Namespace,
|
|
Object: t.Object,
|
|
Relation: t.Relation,
|
|
Subject: rts.NewSubjectSet(ns, obj, ""),
|
|
},
|
|
}
|
|
}
|
|
|
|
if _, err := writeClient.TransactRelationTuples(ctx, &rts.TransactRelationTuplesRequest{
|
|
RelationTupleDeltas: deltas,
|
|
}); err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
fmt.Println("Migration complete")
|
|
}
|