在这个例子中,我们将为OAuth2协议创建Cassandra数据模型。这里的重点是设计模型和实际执行的相关查询。我们不打算创建真正的API端点,但我至少会添加关于每个功能如何使用的例子。
密钥空间
CREATE KEYSPACE IF NOT EXISTS auth
WITH replication = {
'class': 'NetworkTopologyStrategy',
'datacenter1': 1
};
模型
客户
id 字段在代码中没有使用。它可以被用作另一个应用程序的 "外键",那里保存着关于客户的更详细的信息。这完全取决于你的系统设计,所以如果你想,你可以把它删除。
CREATE TABLE IF NOT EXISTS auth.clients (
id uuid,
key uuid,
secret text,
created_at timestamp,
deleted_at timestamp,
PRIMARY KEY (key)
) WITH comment = 'The id field is the external identifier.';
令牌
访问和刷新令牌记录都会有一个TTL,所以这个表的作用就像一个缓存。这是为了防止出现一个巨大的表。
CREATE TABLE IF NOT EXISTS auth.tokens (
hash text,
client_key text,
client_secret text,
scopes set,
PRIMARY KEY (hash, client_key)
) WITH comment = 'Holds both access and refresh tokens identified by type field.';
应用流程
客户端注册
通过接受UUID v4格式的外部标识符来创建一个新客户:
POST /api/v1/clients HTTP/1.1
Content-Type: application/json
{
"id": "xxxxxxxx"
}
HTTP/1.1 201 Created
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache
{
"key":"xxxxxxxx",
"secret":"xxxxxxxx"
}
创建令牌 (client_credentials)
创建一个新的访问和刷新令牌:
POST /oauth/token HTTP/1.1
Authorization: Basic {client_key+client_secret}
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache
{
"access_token":"xxxxxxxx",
"token_type":"bearer",
"expires_in":3600,
"refresh_token":"xxxxxxxx"
}
刷新令牌 (refresh_token)
创建一个新的访问和旋转刷新令牌:
POST /oauth/token HTTP/1.1
Authorization: Basic {client_key+client_secret}
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
refresh_token=xxxxxxxx
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache
{
"access_token":"xxxxxxxx",
"token_type":"bearer",
"expires_in":3600,
"refresh_token":"xxxxxxxx"
}
撤销令牌
撤销属于一个客户的访问令牌或刷新令牌:
POST /oauth/revoke HTTP/1.1
Authorization: Basic {client_key+client_secret}
Content-Type: application/x-www-form-urlencoded
token=xxxxxxxx
HTTP/1.1 200 OK
消耗API
在进行受保护的API请求之前,验证一个访问令牌:
{Method} {endpoint} HTTP/1.1
Authorization: Bearer {access_token}
结构
├── docker
│ └── docker-compose.yaml
├── internal
│ └── pkg
│ ├── cassandra
│ │ └── cassandra.go
│ └── storage
│ ├── driver
│ │ └── cassandra
│ │ ├── client.go
│ │ └── token.go
│ ├── error.go
│ ├── manager.go
│ ├── model.go
│ └── type.go
└── main.go
文件
docker-compose.yaml
version: "3.7"
services:
auth-cassandra:
image: "cassandra:3.11.9"
container_name: "auth-cassandra"
ports:
- "9042:9042"
environment:
- "MAX_HEAP_SIZE=256M"
- "HEAP_NEWSIZE=128M"
cassandra.go
package cassandra
import (
"time"
"github.com/gocql/gocql"
)
// The `gocql: no response received from cassandra within timeout period` error
// will be prevented by increasing the default timeout value. e.g. 5 sec
type Config struct {
Hosts []string
Port int
ProtoVersion int
Consistency string
Keyspace string
Timeout time.Duration
}
func New(config Config) (*gocql.Session, error) {
cluster := gocql.NewCluster(config.Hosts...)
cluster.Port = config.Port
cluster.ProtoVersion = config.ProtoVersion
cluster.Keyspace = config.Keyspace
cluster.Consistency = gocql.ParseConsistency(config.Consistency)
cluster.Timeout = config.Timeout
return cluster.CreateSession()
}
error.go
package storage
import "errors"
var (
ErrDuplication = errors.New("duplicated record")
ErrNotFound = errors.New("record not found")
)
type.go
package storage
type TokenScope string
const (
TokenScopeUnlimited TokenScope = "*"
TokenScopeCreateLeague TokenScope = "create-league"
TokenScopeReadLeague TokenScope = "read-league"
TokenScopeUpdateLeague TokenScope = "update-league"
TokenScopeDeleteLeague TokenScope = "delete-league"
)
type TokenTTL int
const (
TokenTTLAccess TokenTTL = 3600
TokenTTLRefresh TokenTTL = 86400
)
model.go
package storage
import "time"
type Client struct {
ID string
Key string
Secret string
CreatedAt time.Time
DeletedAt *time.Time
}
type Token struct {
ClientKey string
ClientSecret string
Hash string
TTL TokenTTL
Scopes []TokenScope
}
manager.go
package storage
import "context"
type ClientManager interface {
Create(ctx context.Context, client Client) error
Find(ctx context.Context, key string) (Client, error)
UpdateSecret(ctx context.Context, key, secret string) error
SoftDelete(ctx context.Context, key string) error
HardDelete(ctx context.Context, key string) error
}
type TokenManager interface {
// Creates a new access and refresh token.
// Used for `client_credentials` grant type.
Create(ctx context.Context, accTok Token, refTok Token) error
// Deletes the current refresh token then creates a new access token and refresh token.
// Used for `refresh_token` grant type.
// The actual refresh token hash should be looked up with `Find` function before coming this stage.
// Access token is not deleted as the `refresh_token` grant type has no knowledge it. Also, it could
// have had expired anyway unless the client is requesting to refresh before expiry. Either way, it
// is not a problem.
Refresh(ctx context.Context, refTokHash string, accTok Token, refTok Token) error
// Revokes either an access token or a refresh token.
// The actual token hash should be looked up with `Find` function before coming this stage.
Revoke(ctx context.Context, hash string) error
// Finds either an access token or a refresh token.
// Used for `refresh_token` grant type and all protected API calls.
Find(ctx context.Context, hash string) (Token, error)
}
客户端
package cassandra
import (
"context"
"time"
"github.com/you/auth/internal/pkg/storage"
"github.com/gocql/gocql"
)
var _ storage.ClientManager = Client{}
type Client struct {
connection *gocql.Session
timeout time.Duration
}
func NewClient(connection *gocql.Session, timeout time.Duration) Client {
return Client{
connection: connection,
timeout: timeout,
}
}
func (c Client) Create(ctx context.Context, client storage.Client) error {
ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
qry := `
INSERT INTO clients
(id, key, secret, created_at, deleted_at)
VALUES
(?, ?, ?, ?, ?)
IF NOT EXISTS
`
apl, err := c.connection.Query(qry,
client.ID,
client.Key,
client.Secret,
client.CreatedAt,
client.DeletedAt,
).WithContext(ctx).MapScanCAS(map[string]interface{}{})
if err != nil {
return err
}
if !apl {
return storage.ErrDuplication
}
return nil
}
func (c Client) Find(ctx context.Context, key string) (storage.Client, error) {
ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
var client storage.Client
qry := `
SELECT id, key, secret, created_at, deleted_at
FROM clients
WHERE key = ?
`
err := c.connection.Query(qry, key).WithContext(ctx).Scan(
&client.ID,
&client.Key,
&client.Secret,
&client.CreatedAt,
&client.DeletedAt,
)
if err != nil {
if err == gocql.ErrNotFound {
err = storage.ErrNotFound
}
return storage.Client{}, err
}
return client, nil
}
func (c Client) UpdateSecret(ctx context.Context, key, secret string) error {
ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
qry := `
UPDATE clients
SET secret = ?
WHERE key = ?
IF EXISTS
`
apl, err := c.connection.Query(qry, secret, key).WithContext(ctx).MapScanCAS(map[string]interface{}{})
if err != nil {
return err
}
if !apl {
return storage.ErrNotFound
}
return nil
}
func (c Client) SoftDelete(ctx context.Context, key string) error {
ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
qry := `
UPDATE clients
SET deleted_at = ?
WHERE key = ?
IF EXISTS
`
apl, err := c.connection.Query(qry, time.Now().UTC(), key).WithContext(ctx).MapScanCAS(map[string]interface{}{})
if err != nil {
return err
}
if !apl {
return storage.ErrNotFound
}
return nil
}
func (c Client) HardDelete(ctx context.Context, key string) error {
ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
qry := `
DELETE FROM clients
WHERE key = ?
IF EXISTS
`
apl, err := c.connection.Query(qry, key).WithContext(ctx).MapScanCAS(map[string]interface{}{})
if err != nil {
return err
}
if !apl {
return storage.ErrNotFound
}
return nil
}
符号.go
package cassandra
import (
"context"
"time"
"github.com/you/auth/internal/pkg/storage"
"github.com/gocql/gocql"
)
var _ storage.TokenManager = Token{}
type Token struct {
connection *gocql.Session
timeout time.Duration
}
func NewToken(connection *gocql.Session, timeout time.Duration) Token {
return Token{
connection: connection,
timeout: timeout,
}
}
func (t Token) Create(ctx context.Context, accTok storage.Token, refTok storage.Token) error {
ctx, cancel := context.WithTimeout(ctx, t.timeout)
defer cancel()
qry := `
INSERT INTO tokens
(hash, client_key, client_secret, scopes)
VALUES
(?, ?, ?, ?)
USING TTL ?
`
btc := t.connection.NewBatch(gocql.LoggedBatch).WithContext(ctx)
btc.Query(qry, accTok.Hash, accTok.ClientKey, accTok.ClientSecret, accTok.Scopes, accTok.TTL)
btc.Query(qry, refTok.Hash, refTok.ClientKey, refTok.ClientSecret, refTok.Scopes, refTok.TTL)
return t.connection.ExecuteBatch(btc)
}
func (t Token) Refresh(ctx context.Context, refTokHash string, accTok storage.Token, refTok storage.Token) error {
ctx, cancel := context.WithTimeout(ctx, t.timeout)
defer cancel()
qry1 := `
DELETE FROM tokens
WHERE hash = ?
`
qry2 := `
INSERT INTO tokens
(hash, client_key, client_secret, scopes)
VALUES
(?, ?, ?, ?)
USING TTL ?
`
btc := t.connection.NewBatch(gocql.LoggedBatch).WithContext(ctx)
btc.Query(qry1, refTokHash)
btc.Query(qry2, accTok.Hash, accTok.ClientKey, accTok.ClientSecret, accTok.Scopes, accTok.TTL)
btc.Query(qry2, refTok.Hash, refTok.ClientKey, refTok.ClientSecret, refTok.Scopes, refTok.TTL)
return t.connection.ExecuteBatch(btc)
}
func (t Token) Revoke(ctx context.Context, hash string) error {
ctx, cancel := context.WithTimeout(ctx, t.timeout)
defer cancel()
qry := `
DELETE FROM tokens
WHERE hash = ?
`
return t.connection.Query(qry, hash).WithContext(ctx).Exec()
}
func (t Token) Find(ctx context.Context, hash string) (storage.Token, error) {
ctx, cancel := context.WithTimeout(ctx, t.timeout)
defer cancel()
var token storage.Token
qry := `
SELECT hash, client_key, client_secret, scopes
FROM tokens
WHERE hash = ?
`
err := t.connection.Query(qry, hash).WithContext(ctx).Scan(
&token.Hash,
&token.ClientKey,
&token.ClientSecret,
&token.Scopes,
)
if err != nil {
if err == gocql.ErrNotFound {
err = storage.ErrNotFound
}
return storage.Token{}, err
}
return token, nil
}
主程序
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/you/auth/internal/pkg/cassandra"
"github.com/you/auth/internal/pkg/storage"
storagemanager "github.com/you/auth/internal/pkg/storage/driver/cassandra"
)
func main() {
// Cassandra connection
cass, err := cassandra.New(cassandra.Config{
Hosts: []string{"127.0.0.1"},
Port: 9042,
ProtoVersion: 4,
Consistency: "Quorum",
Keyspace: "auth",
Timeout: time.Second * 5,
})
if err != nil {
log.Fatalln(err)
}
defer cass.Close()
// Create cancellable context.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Create client storage manager.
clientManager := storagemanager.NewClient(cass, time.Second)
// Create client.
createClient(ctx, clientManager)
// Find client.
findClient(ctx, clientManager)
// Update client secret.
updateClientSecret(ctx, clientManager)
// Soft delete client.
softDeleteClient(ctx, clientManager)
// Hard delete client.
hardDeleteClient(ctx, clientManager)
// Create token storage manager.
tokenManager := storagemanager.NewToken(cass, time.Second)
// Create token.
createToken(ctx, tokenManager)
// Find token.
findToken(ctx, tokenManager)
// Refresh token.
refreshToken(ctx, tokenManager)
// Revoke token.
revokeToken(ctx, tokenManager)
}
/**
------------------------------------------------------------------------------------------
CLIENT
------------------------------------------------------------------------------------------
*/
func createClient(ctx context.Context, clientManager storage.ClientManager) {
if err := clientManager.Create(ctx, storage.Client{
ID: "7a5481cd-4d2b-47f1-8336-691efb67d45a",
Key: "ec3b7eaf-e7b9-46e1-877e-6bf8e2cc405b",
Secret: "8pt1kN4Urirt77DwCeAPz69DAZ0guCVtPFk6",
CreatedAt: time.Now().UTC(),
DeletedAt: &time.Time{},
}); err != nil {
fmt.Println("create client:", err)
}
fmt.Println("create client: ok")
}
func findClient(ctx context.Context, clientManager storage.ClientManager) {
res, err := clientManager.Find(ctx, "ec3b7eaf-e7b9-46e1-877e-6bf8e2cc405b")
if err != nil {
fmt.Println("find client:", err)
return
}
fmt.Printf("find client: %+v\n", res)
}
func updateClientSecret(ctx context.Context, clientManager storage.ClientManager) {
if err := clientManager.UpdateSecret(ctx,
"ec3b7eaf-e7b9-46e1-877e-6bf8e2cc405b",
"grWiC12EW6tBim6Si1CjvkC6xVOmtRyuRpok",
); err != nil {
fmt.Println("update client secret:", err)
return
}
fmt.Println("update client secret: ok")
}
func softDeleteClient(ctx context.Context, clientManager storage.ClientManager) {
if err := clientManager.SoftDelete(ctx, "be2be55c-04fb-4b1c-b41b-fdfc20b81ff8"); err != nil {
fmt.Println("soft delete client:", err)
return
}
fmt.Println("soft delete client: ok")
}
func hardDeleteClient(ctx context.Context, clientManager storage.ClientManager) {
if err := clientManager.HardDelete(ctx, "aaaeb5a9-0ca2-4dce-949c-75bd268a15a5"); err != nil {
fmt.Println("hard delete client:", err)
return
}
fmt.Println("hard delete client: ok")
}
/**
------------------------------------------------------------------------------------------
TOKEN
------------------------------------------------------------------------------------------
*/
func createToken(ctx context.Context, tokenManager storage.TokenManager) {
accTok := storage.Token{
ClientKey: "be2be55c-04fb-4b1c-b41b-fdfc20b81ff8",
ClientSecret: "cbHR4JaAw8go1vPxUxW9a5rwl8D4uFDN8HfV", // Should be stored as, e.g. Argon2id
Hash: "ub4BQJSU0KfzkqbZTpqsZVvTCflC0foIw7xVdtbGVRyADnTwKQ", // Should be, e.g. 255 length
TTL: storage.TokenTTLAccess,
Scopes: []storage.TokenScope{storage.TokenScopeCreateLeague, storage.TokenScopeReadLeague},
}
refTok := storage.Token{
ClientKey: "be2be55c-04fb-4b1c-b41b-fdfc20b81ff8",
ClientSecret: "cbHR4JaAw8go1vPxUxW9a5rwl8D4uFDN8HfV", // Should be stored as, e.g. Argon2id
Hash: "WN2HxZiUsGLy2ZWpUGOjG5Bn7masaVlaBRGXTS3noo44YdMJhT", // Should be, e.g. 255 length
TTL: storage.TokenTTLRefresh,
Scopes: []storage.TokenScope{storage.TokenScopeCreateLeague, storage.TokenScopeReadLeague},
}
if err := tokenManager.Create(ctx, accTok, refTok); err != nil {
fmt.Println("create token:", err)
return
}
fmt.Println("create token: ok")
}
func findToken(ctx context.Context, tokenManager storage.TokenManager) {
res, err := tokenManager.Find(ctx, "ub4BQJSU0KfzkqbZTpqsZVvTCflC0foIw7xVdtbGVRyADnTwKQ")
if err != nil {
fmt.Println("find token:", err)
return
}
fmt.Printf("find token: %+v\n", res)
}
func refreshToken(ctx context.Context, tokenManager storage.TokenManager) {
tok, err := tokenManager.Find(ctx, "WN2HxZiUsGLy2ZWpUGOjG5Bn7masaVlaBRGXTS3noo44YdMJhT")
if err != nil {
fmt.Println("refresh token: token not found: 401:", err)
return
}
accTok := storage.Token{
ClientKey: tok.ClientKey,
ClientSecret: tok.ClientSecret, // Already Argon2id at this stage
Hash: "q1kOUVJbsQm2ZUQ7VeS91ODjYaUBPHRRmTztSgJ5GTk1IqPmOi", // Should be, e.g. 255 length
TTL: storage.TokenTTLAccess,
Scopes: tok.Scopes,
}
refTok := storage.Token{
ClientKey: tok.ClientKey,
ClientSecret: tok.ClientSecret, // Already Argon2id at this stage
Hash: "ZJNgenlyow5uMFaCE1pQRuDWHUME48xRsYu5w2j2ZjqNHCho9T", // Should be, e.g. 255 length
TTL: storage.TokenTTLRefresh,
Scopes: tok.Scopes,
}
if err := tokenManager.Refresh(ctx, tok.Hash, accTok, refTok); err != nil {
fmt.Println("refresh token:", err)
return
}
fmt.Println("refresh token: ok")
}
func revokeToken(ctx context.Context, tokenManager storage.TokenManager) {
clientKey := "be2be55c-04fb-4b1c-b41b-fdfc20b81ff8"
clientSec := "cbHR4JaAw8go1vPxUxW9a5rwl8D4uFDN8HfV"
hash := "ZJNgenlyow5uMFaCE1pQRuDWHUME48xRsYu5w2j2ZjqNHCho9T"
tok, err := tokenManager.Find(ctx, hash)
if err != nil {
fmt.Println("revoke token: token not found: 401:", err)
return
}
if clientKey != tok.ClientKey || clientSec != tok.ClientSecret {
fmt.Println("revoke token: invalid client: 401")
return
}
if err := tokenManager.Revoke(ctx, tok.Hash); err != nil {
fmt.Println("revoke token:", err)
return
}
fmt.Println("revoke token: ok")
}
测试
当前状态
cqlsh> SELECT * FROM auth.clients;
key | created_at | deleted_at | id | secret
--------------------------------------+---------------------------------+---------------------------------+--------------------------------------+--------------------------------------
be2be55c-04fb-4b1c-b41b-fdfc20b81ff8 | 2019-11-27 10:00:49.000000+0000 | | 2c62c3d8-55ae-46ed-89bb-350e33bfb602 | cbHR4JaAw8go1vPxUxW9a5rwl8D4uFDN8HfV
d7cc525a-98c4-48b3-9520-f8f9ee1d9c7b | 2019-11-27 10:00:49.000000+0000 | | 32534bcd-c9ed-4613-a478-1539856386d5 | 7hTmGjGdOjpdlynE7SzkynK6l5ySbckRRlnd
c291ecc3-5a98-45ac-8473-6486f86058d0 | 2021-01-17 13:09:49.000000+0000 | 2021-12-17 13:00:11.000000+0000 | 152e3280-baa7-452a-a5d5-d22ede044e5e | Qlr83GgYIppCdvrUfXr8vQRkgqj04nqgc8Q1
aaaeb5a9-0ca2-4dce-949c-75bd268a15a5 | 2020-01-17 11:09:00.000000+0000 | | 4b3bef03-2eb3-4783-9e68-9e4b443f0fdf | y6BIS7Z2BtEx1aeYusTq8Zd0VqC6Mx4dRcmr
f74b6770-8e68-4c82-88e3-17b6c9362b8f | 2019-11-27 10:00:49.000000+0000 | 2021-01-30 22:45:49.000000+0000 | 5962f914-a146-452e-bb9c-661534abdef4 | Rtl09mcaFWX7kcgD2ZndvzEHn8HVorn9rEUp
(5 rows)
cqlsh> SELECT * FROM auth.tokens;
hash | client_key | client_secret | scopes
------+------------+---------------+--------
(0 rows)
新状态
在这个阶段,运行go run -race main.go 命令:
$ go run -race main.go
create client: ok
find client: {ID:7a5481cd-4d2b-47f1-8336-691efb67d45a Key:ec3b7eaf-e7b9-46e1-877e-6bf8e2cc405b Secret:8pt1kN4Urirt77DwCeAPz69DAZ0guCVtPFk6 CreatedAt:2021-02-01 15:11:23.545 +0000 UTC DeletedAt:0001-01-01 00:00:00 +0000 UTC}
update client secret: ok
soft delete client: ok
hard delete client: ok
create token: ok
find token: {ClientKey:be2be55c-04fb-4b1c-b41b-fdfc20b81ff8 ClientSecret:cbHR4JaAw8go1vPxUxW9a5rwl8D4uFDN8HfV Hash:ub4BQJSU0KfzkqbZTpqsZVvTCflC0foIw7xVdtbGVRyADnTwKQ TTL:0 Scopes:[create-league read-league]}
refresh token: ok
revoke token: ok
cqlsh> SELECT * FROM auth.clients;
key | created_at | deleted_at | id | secret
--------------------------------------+---------------------------------+---------------------------------+--------------------------------------+--------------------------------------
be2be55c-04fb-4b1c-b41b-fdfc20b81ff8 | 2019-11-27 10:00:49.000000+0000 | 2021-02-01 15:11:23.620000+0000 | 2c62c3d8-55ae-46ed-89bb-350e33bfb602 | cbHR4JaAw8go1vPxUxW9a5rwl8D4uFDN8HfV
ec3b7eaf-e7b9-46e1-877e-6bf8e2cc405b | 2021-02-01 15:11:23.545000+0000 | | 7a5481cd-4d2b-47f1-8336-691efb67d45a | grWiC12EW6tBim6Si1CjvkC6xVOmtRyuRpok
d7cc525a-98c4-48b3-9520-f8f9ee1d9c7b | 2019-11-27 10:00:49.000000+0000 | | 32534bcd-c9ed-4613-a478-1539856386d5 | 7hTmGjGdOjpdlynE7SzkynK6l5ySbckRRlnd
c291ecc3-5a98-45ac-8473-6486f86058d0 | 2021-01-17 13:09:49.000000+0000 | 2021-12-17 13:00:11.000000+0000 | 152e3280-baa7-452a-a5d5-d22ede044e5e | Qlr83GgYIppCdvrUfXr8vQRkgqj04nqgc8Q1
f74b6770-8e68-4c82-88e3-17b6c9362b8f | 2019-11-27 10:00:49.000000+0000 | 2021-01-30 22:45:49.000000+0000 | 5962f914-a146-452e-bb9c-661534abdef4 | Rtl09mcaFWX7kcgD2ZndvzEHn8HVorn9rEUp
(5 rows)
cqlsh> SELECT * FROM auth.tokens;
hash | client_key | client_secret | scopes
----------------------------------------------------+--------------------------------------+--------------------------------------+----------------------------------
q1kOUVJbsQm2ZUQ7VeS91ODjYaUBPHRRmTztSgJ5GTk1IqPmOi | be2be55c-04fb-4b1c-b41b-fdfc20b81ff8 | cbHR4JaAw8go1vPxUxW9a5rwl8D4uFDN8HfV | {'create-league', 'read-league'}
ub4BQJSU0KfzkqbZTpqsZVvTCflC0foIw7xVdtbGVRyADnTwKQ | be2be55c-04fb-4b1c-b41b-fdfc20b81ff8 | cbHR4JaAw8go1vPxUxW9a5rwl8D4uFDN8HfV | {'create-league', 'read-league'}
(2 rows)