In this example we are going to create Cassandra data models for OAuth2 protocol. The main point here is to design models and the relevant queries for the actual implementation. We are not going to create real API endpoints but I will at least add examples on how each functions are used.


Keyspace


CREATE KEYSPACE IF NOT EXISTS auth
WITH replication = {
'class': 'NetworkTopologyStrategy',
'datacenter1': 1
};

Models


Clients


The id field is not used in the code. It could be used as a "foreign key" to an another application where more detailed information about the client is kept. It all depends on your system design so you can remove it if you want.


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.';

Tokens


Both access and refresh token records will have a TTL attached to them so this table behaves like a cache. This is to prevent having a giant table.


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.';

Application flow


Client Registration


Create a new client by accepting its external identifier in UUID v4 format.


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"
}

Create token (client_credentials)


Creates a new access and refresh token.


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 (refresh_token)


Creates a new access and rotates 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"
}

Revoke token


Revokes either an access token or a refresh token belongs to a client.


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

Consuming API


Verifies an access token before proceeding with the protected API requests.


{Method} {endpoint} HTTP/1.1
Authorization: Bearer {access_token}

Structure


├── 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

Files


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)
}

client.go


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
}

token.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
}

main.go


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")
}

Test


Current state


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)

New state


At this stage run go run -race main.go command.


$ 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)