Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ linters:
allow:
- $gostd
- github.com/charmbracelet/huh
- github.com/charmbracelet/lipgloss
- github.com/gocarina/gocsv
- github.com/hashicorp/go-multierror
- github.com/mattn/go-isatty
Expand Down
43 changes: 41 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ A cross-platform CLI to interact with an OpenFGA server
- [Run Embedded Tests](#test-mapping)
- [Scaffold a Mapping File](#init-mapping)
- [Evaluate Against JSON Input](#run-mapping)
- [Sync to Store](#sync-mapping)
- [Contributing](#contributing)
- [License](#license)

Expand Down Expand Up @@ -1217,8 +1218,7 @@ fga query **list-users** --object <object> --relation <relation> --user-filter <

#### Mapping

Offline tooling for authoring, validating, testing, and evaluating JSON→tuple mapping files.
No store credentials or network access required.
Tooling for authoring, validating, testing, evaluating, and applying JSON→tuple mapping files. Most commands run offline; `sync` requires store credentials.

- `mapping`

Expand All @@ -1227,6 +1227,7 @@ No store credentials or network access required.
| [Scaffold a mapping file](#init-mapping) | `init` | `[mapping.yaml]`, `--minimal`, `--force` | `fga mapping init` |
| [Validate a mapping file](#validate-mapping) | `validate` | `--format`, `--model-file`, `--verbose` | `fga mapping validate mapping.yaml` |
| [Run embedded tests](#test-mapping) | `test` | `--format`, `--run`, `--fail-fast`, `--output-file`, `--verbose`, `--no-color` | `fga mapping test mapping.yaml` |
| [Sync to store](#sync-mapping) | `sync` | `--store-id`, `--model-id`, `--input`, `--dry-run`, `--continue-on-error`, `--quiet` | `fga mapping sync mapping.yaml --store-id $FGA_STORE_ID < events.jsonl` |

##### Validate Mapping

Expand Down Expand Up @@ -1357,6 +1358,44 @@ Error: invalid JSON at line 1, column 8: invalid character 'b' looking for begin
> :quit
```

##### Sync Mapping

###### Command
fga mapping **sync** [mapping-file]

Reads JSONL from stdin (or `--input`) and evaluates each record against the mapping file, then applies the resulting tuple writes and deletes to the store. Rules using `tuple_filters` read current store state, diff against desired state, and derive the minimal write/delete set before applying. Deletes are issued before writes so that a condition change on the same user–relation–object (delete old + write new) lands correctly.

The mapping file is optional: omit it in an interactive terminal to choose one from a `.yaml`/`.yml` file picker.

###### Parameters
* `--store-id` (required): Store ID
* `--model-id`: Authorization Model ID
* `--input`: Path to a JSONL input file, one JSON object per line (default: stdin)
* `--dry-run`: Read the store and print planned operations to stdout as JSONL instead of writing them. Suppressed at an interactive terminal where the output would be unreadable.
* `--continue-on-error`: Skip records that fail to evaluate or apply (shown on stderr) and exit non-zero if any were skipped
* `--quiet`: Suppress progress output; only errors are written to stderr

###### Example
`echo '{"id":"anne","org":"acme"}' | fga mapping sync mapping.yaml --store-id $FGA_STORE_ID`

`fga mapping sync mapping.yaml --input events.jsonl --store-id $FGA_STORE_ID --dry-run > planned.jsonl`

`fga mapping sync mapping.yaml --store-id $FGA_STORE_ID --continue-on-error < events.jsonl`

###### Response

Progress is shown on stderr as a single updating line (or once per second when not a terminal):
```
15:04:05 ✓ +12 -3 10 records
```
With `--quiet`, only errors appear on stderr.

`--dry-run` (with stdout redirected) emits planned operations as JSONL:
```json
{"op":"write","user":"user:anne","relation":"member","object":"org:acme"}
{"op":"delete","user":"user:bob","relation":"member","object":"org:acme"}
```

## Contributing

See [CONTRIBUTING](https://github.com/openfga/.github/blob/main/CONTRIBUTING.md).
Expand Down
250 changes: 250 additions & 0 deletions cmd/mapping/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
package mapping

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"

openfga "github.com/openfga/go-sdk"
sdkclient "github.com/openfga/go-sdk/client"
"github.com/openfga/mapper/apply"
"github.com/openfga/mapper/language"

internaltuple "github.com/openfga/cli/internal/tuple"
)

const maxWriteChunkSize = 40

// sdkWriteReadClient is the subset of sdkclient.SdkClient used by fgaTupleClient.
type sdkWriteReadClient interface {
Read(ctx context.Context) sdkclient.SdkClientReadRequestInterface
Write(ctx context.Context) sdkclient.SdkClientWriteRequestInterface
}

// fgaTupleClient adapts the OpenFGA SDK client to the apply.TupleClient interface.
type fgaTupleClient struct {
inner sdkWriteReadClient
}

func (c *fgaTupleClient) ReadTuples(ctx context.Context, filter language.TupleFilter) ([]language.Tuple, error) {
body := &sdkclient.ClientReadRequest{}
if filter.User != "" {
body.User = &filter.User
}
if filter.Relation != "" {
body.Relation = &filter.Relation
}
if filter.Object != "" {
body.Object = &filter.Object
}

consistency := openfga.CONSISTENCYPREFERENCE_HIGHER_CONSISTENCY
resp, err := internaltuple.Read(ctx, c.inner, body, 0, internaltuple.DefaultReadPageSize, &consistency)
if err != nil {
return nil, err
}

tuples := make([]language.Tuple, 0, len(resp.Tuples))
for _, t := range resp.Tuples {
key := t.GetKey()
tuple := language.Tuple{
User: key.GetUser(),
Relation: key.GetRelation(),
Object: key.GetObject(),
}
if cond, ok := key.GetConditionOk(); ok {
tuple.Condition = cond.GetName()
tuple.Context = cond.GetContext()
}
tuples = append(tuples, tuple)
}

return tuples, nil
}

func (c *fgaTupleClient) WriteTuples(ctx context.Context, tuples []language.Tuple) error {
writes, deletes := partitionSyncTuples(tuples)
if len(writes) == 0 && len(deletes) == 0 {
return nil
}

writeOpts := sdkclient.ClientWriteOptions{
Conflict: sdkclient.ClientWriteConflictOptions{
OnDuplicateWrites: sdkclient.CLIENT_WRITE_REQUEST_ON_DUPLICATE_WRITES_IGNORE,
OnMissingDeletes: sdkclient.CLIENT_WRITE_REQUEST_ON_MISSING_DELETES_IGNORE,
Comment thread
ewanharris marked this conversation as resolved.
},
Transaction: &sdkclient.TransactionOptions{
Disable: true,
MaxPerChunk: maxWriteChunkSize,
},
}

// Deletes must be issued before writes. The OpenFGA server rejects a write to a
// URO that already holds a tuple with a different condition (TupleConditionConflictError),
// so a condition change (delete old + write new on same URO) requires the delete to
// land first. The SDK with Transaction.Disable sends writes first then deletes, so we
// split them into two calls.
if len(deletes) > 0 {
Comment thread
ewanharris marked this conversation as resolved.
if err := c.executeDeletes(ctx, writeOpts, deletes); err != nil {
return err
}
}

if len(writes) > 0 {
if err := c.executeWrites(ctx, writeOpts, writes); err != nil {
return err
}
}

return nil
}

func (c *fgaTupleClient) executeDeletes(
ctx context.Context,
opts sdkclient.ClientWriteOptions,
deletes []sdkclient.ClientTupleKeyWithoutCondition,
) error {
resp, err := c.inner.Write(ctx).Options(opts).Body(sdkclient.ClientWriteRequest{
Deletes: deletes,
}).Execute()
if err != nil {
return fmt.Errorf("deleting tuples: %w", err)
}

var errs []error
seen := make(map[string]struct{})
for _, d := range resp.Deletes {
if d.Status == sdkclient.FAILURE {
var e error
if d.Error != nil {
e = d.Error
} else {
e = fmt.Errorf("delete failed: %s %s %s", d.TupleKey.User, d.TupleKey.Relation, d.TupleKey.Object)
}
if _, ok := seen[e.Error()]; !ok {
seen[e.Error()] = struct{}{}
errs = append(errs, e)
}
}
}
if len(errs) > 0 {
return fmt.Errorf("deleting tuples: %w", errors.Join(errs...))
}

return nil
}

func (c *fgaTupleClient) executeWrites(
ctx context.Context,
opts sdkclient.ClientWriteOptions,
writes []sdkclient.ClientTupleKey,
) error {
resp, err := c.inner.Write(ctx).Options(opts).Body(sdkclient.ClientWriteRequest{
Writes: writes,
}).Execute()
if err != nil {
return fmt.Errorf("writing tuples: %w", err)
}

var errs []error
seen := make(map[string]struct{})
for _, w := range resp.Writes {
if w.Status == sdkclient.FAILURE {
var e error
if w.Error != nil {
e = w.Error
} else {
e = fmt.Errorf("write failed: %s %s %s", w.TupleKey.User, w.TupleKey.Relation, w.TupleKey.Object)
}
if _, ok := seen[e.Error()]; !ok {
seen[e.Error()] = struct{}{}
errs = append(errs, e)
}
}
}
if len(errs) > 0 {
return fmt.Errorf("writing tuples: %w", errors.Join(errs...))
}

return nil
}

// countingClient wraps a TupleClient and tracks how many tuples were written/deleted.
// Reset writes and dels between records. Correctness depends on apply setting t.Action
// on every tuple it passes to WriteTuples; an unset Action is treated as a write.
type countingClient struct {
inner apply.TupleClient
writes int
dels int
}

func (c *countingClient) ReadTuples(ctx context.Context, filter language.TupleFilter) ([]language.Tuple, error) {
return c.inner.ReadTuples(ctx, filter)
}

func (c *countingClient) WriteTuples(ctx context.Context, tuples []language.Tuple) error {
for _, t := range tuples {
if t.Action == language.ActionDelete {
c.dels++
} else {
c.writes++
}
}
return c.inner.WriteTuples(ctx, tuples)
}

// printOnlyClient reads from inner but prints planned writes/deletes to out instead of applying them.
// Used for --dry-run.
type printOnlyClient struct {
inner apply.TupleClient
out io.Writer
}

func (c *printOnlyClient) ReadTuples(ctx context.Context, filter language.TupleFilter) ([]language.Tuple, error) {
return c.inner.ReadTuples(ctx, filter)
}

func (c *printOnlyClient) WriteTuples(_ context.Context, tuples []language.Tuple) error {
enc := json.NewEncoder(c.out)
for _, t := range tuples {
if err := enc.Encode(toOpTuple(t)); err != nil {
return fmt.Errorf("encoding dry-run output: %w", err)
}
}
return nil
}

func partitionSyncTuples(tuples []language.Tuple) ([]sdkclient.ClientTupleKey, []sdkclient.ClientTupleKeyWithoutCondition) {
var writes []sdkclient.ClientTupleKey
var deletes []sdkclient.ClientTupleKeyWithoutCondition

for _, t := range tuples {
if t.Action == language.ActionDelete {
deletes = append(deletes, sdkclient.ClientTupleKeyWithoutCondition{
User: t.User,
Relation: t.Relation,
Object: t.Object,
})

continue
}

key := sdkclient.ClientTupleKey{
User: t.User,
Relation: t.Relation,
Object: t.Object,
}
if t.Condition != "" {
rc := &openfga.RelationshipCondition{Name: t.Condition}
if len(t.Context) > 0 {
rc.Context = &t.Context
}
key.Condition = rc
}
writes = append(writes, key)
}
Comment thread
ewanharris marked this conversation as resolved.

return writes, deletes
}
Loading
Loading