diff --git a/examples/delegated/generate.sh b/examples/delegated/generate.sh old mode 100644 new mode 100755 diff --git a/examples/dynamic_accounts/README.md b/examples/dynamic_accounts/README.md new file mode 100644 index 0000000..623db76 --- /dev/null +++ b/examples/dynamic_accounts/README.md @@ -0,0 +1,31 @@ +## Dynamic Accounts (Proof of Concept) + +Using a NATS resolver it is possible to create accounts on the fly to place +users. This has an interesting niche use-case. All of it can be accomplished via +a callout, so long as the callout can create an account, deploy it, and create +the user within the authentication window. + +Worse that could happen is the first connection could fail, but eventually the +server would be aware of the account, and the connection would proceed. + +And a [Go program](dynamic.go) that uses the library and the results of the +above script to run a service. And a client [Go Program](client/client.go) + +To run, execute the script, and then + +```bash +# the script will put things in /tmp/DA +nats-server -c /tmp/DA/server.conf + +# in another terminal run the callout service: +go run dynamic.go -operator-key /tmp/DA/operator.nk -sys /tmp/DA/sys.creds -callout-issuer /tmp/DA/C.nk -creds /tmp/DA/service.creds + +# in another terminal try the callout with the client program: +cd client +go run client.go -account-name B -creds /tmp/DA/sentinel.creds + +{"server":{"name":"ND4SGVPVMOHYB3BISPYKWE3RUSILPCAFBV74AEFYFJBRWSXPN6FGKSGG","host":"0.0.0.0","id":"ND4SGVPVMOHYB3BISPYKWE3RUSILPCAFBV74AEFYFJBRWSXPN6FGKSGG","ver":"2.11.0-dev","jetstream":false,"flags":0,"seq":127,"time":"2025-02-04T20:30:09.615143Z"},"data":{"user":"B","account":"AADWDO5UGBLQT2MBC4NCUUHE34TG6KED47OHCPWWXTRHCFOKXGWTRO4F"}} +# but work for anything else +nats -s localhost:4222 --creds /tmp/DA/sentinel.creds pub hello hi +13:03:13 Published 2 bytes to "hello" +``` diff --git a/examples/dynamic_accounts/client/client.go b/examples/dynamic_accounts/client/client.go new file mode 100644 index 0000000..8dff606 --- /dev/null +++ b/examples/dynamic_accounts/client/client.go @@ -0,0 +1,46 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "time" + + "github.com/nats-io/nats.go" +) + +func getConnectionOptions(fp string) ([]nats.Option, error) { + if fp == "" { + return nil, errors.New("creds file required") + } + return []nats.Option{nats.UserCredentials(fp)}, nil +} + +func main() { + // load the creds, and keys + var credsFp, accountName string + // sentinel creds + flag.StringVar(&credsFp, "creds", "./sentinel.creds", "creds file for the client") + // the account the user wants to be placed in + flag.StringVar(&accountName, "account-name", "", "account name") + flag.Parse() + + // connect + opts, err := getConnectionOptions(credsFp) + opts = append(opts, nats.Token(accountName)) + if err != nil { + panic(err) + } + nc, err := nats.Connect("nats://localhost:4222", opts...) + if err != nil { + panic(err) + } + defer nc.Close() + + // find out where we got placed + r, err := nc.Request("$SYS.REQ.USER.INFO", nil, time.Second*2) + if err != nil { + panic(err) + } + fmt.Println(string(r.Data)) +} diff --git a/examples/dynamic_accounts/dynamic.go b/examples/dynamic_accounts/dynamic.go new file mode 100644 index 0000000..3b5f30d --- /dev/null +++ b/examples/dynamic_accounts/dynamic.go @@ -0,0 +1,203 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/aricart/callout.go" + "github.com/nats-io/jwt/v2" + "github.com/nats-io/nats.go" + "github.com/nats-io/nkeys" +) + +func loadAndParseKeys(fp string, kind byte) (nkeys.KeyPair, error) { + if fp == "" { + return nil, errors.New("key file required") + } + seed, err := os.ReadFile(fp) + if err != nil { + return nil, fmt.Errorf("error reading key file: %w", err) + } + if !bytes.HasPrefix(seed, []byte{'S', kind}) { + return nil, fmt.Errorf("key must be a private key") + } + kp, err := nkeys.FromSeed(seed) + if err != nil { + return nil, fmt.Errorf("error parsing key: %w", err) + } + return kp, nil +} + +func getConnectionOptions(fp string) ([]nats.Option, error) { + if fp == "" { + return nil, errors.New("creds file required") + } + return []nats.Option{nats.UserCredentials(fp)}, nil +} + +func UpdateAccount(nc *nats.Conn, token string) (*ResolverUpdateResponse, error) { + var r ResolverUpdateResponse + m, err := nc.Request("$SYS.REQ.CLAIMS.UPDATE", []byte(token), time.Second*2) + if err != nil { + return nil, err + } + err = json.Unmarshal(m.Data, &r) + if err != nil { + return nil, err + } + return &r, nil +} + +type UpdateData struct { + Account string `json:"account"` + Code int `json:"code"` + Message string `json:"message"` +} + +type ResolverResponse struct { + Error *ErrorDetails `json:"error,omitempty"` + Server ServerDetails `json:"server"` +} + +type ServerDetails struct { + Name string `json:"name"` + Host string `json:"host"` + ID string `json:"id"` + Version string `json:"ver"` + Jetstream bool `json:"jetstream"` + Flags int `json:"flags"` + Sequence int `json:"seq"` + Time time.Time `json:"time"` +} + +type ErrorDetails struct { + Account string `json:"account"` + Code int `json:"code"` + Description string `json:"description"` +} + +type ResolverUpdateResponse struct { + ResolverResponse + UpdateData UpdateData `json:"data"` +} + +func main() { + // load the creds, and keys + var credsFp, sysCreds, calloutKeyFp, operatorKeyFp string + flag.StringVar(&credsFp, "creds", "./service.creds", "creds file for the service") + flag.StringVar(&sysCreds, "sys", "./sys.creds", "system creds") + flag.StringVar(&calloutKeyFp, "callout-issuer", "./C.nk", "key for signing callout responses") + flag.StringVar(&operatorKeyFp, "operator-key", "./operator.nk", "key for creating accounts") + flag.Parse() + + okp, err := loadAndParseKeys(operatorKeyFp, 'O') + if err != nil { + panic(err) + } + + sysOpts, err := getConnectionOptions(sysCreds) + if err != nil { + panic(err) + } + sys, err := nats.Connect("nats://localhost:4222", sysOpts...) + if err != nil { + panic(err) + } + + _, _ = sys.Subscribe("$SYS.REQ.ACCOUNT.*.CLAIMS.LOOKUP", func(m *nats.Msg) { + chunks := strings.Split(m.Subject, ".") + id := chunks[3] + fmt.Println(id) + }) + + // this creates a new account named as specified returning + // the key used to sign users + createAccount := func(name string) (nkeys.KeyPair, error) { + kp, err := nkeys.CreateAccount() + if err != nil { + return nil, err + } + pk, err := kp.PublicKey() + if err != nil { + return nil, err + } + ac := jwt.NewAccountClaims(pk) + ac.Name = name + token, err := ac.Encode(okp) + if err != nil { + return nil, err + } + r, err := UpdateAccount(sys, token) + if err != nil { + return nil, err + } + // verify that the update worked + if r.UpdateData.Code != 200 { + return nil, fmt.Errorf("error creating account: %s", r.Error.Description) + } + return kp, nil + } + + // keep a map of account names to keys - this would likely need to be more + // sophisticated, and be persistent. Likely some sort of cleanup logic would + // have to be added to delete (set connections to 0) and possibly remove from + // resolver once accounts are vacated - this is an exercise for the reader. + accounts := make(map[string]nkeys.KeyPair) + + // load the callout key + cKP, err := loadAndParseKeys(calloutKeyFp, 'A') + if err != nil { + panic(fmt.Errorf("error loading callout issuer: %w", err)) + } + + // the authorizer function + authorizer := func(req *jwt.AuthorizationRequest) (string, error) { + // reading the account name from the token, likely this will be + // encoded string with more information + accountName := req.ConnectOptions.Token + if accountName == "" { + // fail + return "", errors.New("no account name") + } + // see if we have this account + akp, ok := accounts[accountName] + if !ok { + // create it and push it + akp, err = createAccount(accountName) + if err != nil { + return "", err + } + accounts[accountName] = akp + } + // issue the user + uc := jwt.NewUserClaims(req.UserNkey) + return uc.Encode(akp) + } + + // connect the service with the creds + opts, err := getConnectionOptions(credsFp) + if err != nil { + panic(fmt.Errorf("error loading creds: %w", err)) + } + nc, err := nats.Connect("nats://localhost:4222", opts...) + if err != nil { + panic(fmt.Errorf("error connecting: %w", err)) + } + defer nc.Close() + + // start the service + _, err = callout.NewAuthorizationService(nc, callout.Authorizer(authorizer), callout.ResponseSignerKey(cKP)) + + // don't exit until sigterm + quit := make(chan os.Signal, 1) + signal.Notify(quit, os.Interrupt, syscall.SIGTERM) + <-quit +} diff --git a/examples/dynamic_accounts/generate.sh b/examples/dynamic_accounts/generate.sh new file mode 100755 index 0000000..9073868 --- /dev/null +++ b/examples/dynamic_accounts/generate.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -x + +# put the nsc artifacts where we can find them +export TMPDIR=/tmp +export OUTDIR=$TMPDIR/DA +export XDG_CONFIG_HOME=$OUTDIR/config +export XDG_DATA_HOME=$OUTDIR/data + + +# add an operator +nsc add operator O +#nsc edit operator --account-jwt-server-url nats://localhost:4222 +nsc export keys --operator --dir $OUTDIR +OPERATOR=$(nsc describe operator --json | jq .sub -r) +mv "$OUTDIR/$OPERATOR.nk" $OUTDIR/operator.nk + +# add and register the system account +nsc add account SYS +nsc edit operator --system-account SYS +nsc add user --account SYS --name sys +nsc generate creds --account SYS --name sys -o $OUTDIR/sys.creds + +# add the callout account +nsc add account C +# capture the ID (subject) for the callout account +CALLOUT=$(nsc describe account C --json | jq .sub -r) +cp "$XDG_DATA_HOME/nats/nsc/keys/keys/A/${CALLOUT:1:2}/${CALLOUT}.nk" $OUTDIR/C.nk +# add the service user, this user is for the callout service to connect to NATS +nsc add user service +SERVICE=$(nsc describe user service --json | jq .sub -r) +# add the sentinel users (no permissions) this is a callout user that will be given +# to all clients to authorize via the callout +nsc add user --account C --name sentinel --deny-pubsub \> +# the callout account needs to specify the ID of the service user, and the accounts +# that it can generate authorizations for +nsc edit authcallout --account C --auth-user $SERVICE --allowed-account "*" + +# make a server configuration file +nsc generate config --nats-resolver --config-file /tmp/DA/server.conf +# extract the creds for the service and callout so we can use them +nsc generate creds --account C --name service -o $OUTDIR/service.creds +nsc generate creds --account C --name sentinel -o $OUTDIR/sentinel.creds + +mkdir -p $OUTDIR/jwt +nsc describe account C --raw > "$OUTDIR/jwt/$CALLOUT.jwt" + +