diff --git a/pkg/fga/client.go b/pkg/fga/client.go new file mode 100644 index 00000000..8fe92442 --- /dev/null +++ b/pkg/fga/client.go @@ -0,0 +1,926 @@ +package fga + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "sync" + "time" + + "github.com/google/go-querystring/query" + "github.com/workos/workos-go/v4/internal/workos" + "github.com/workos/workos-go/v4/pkg/common" + "github.com/workos/workos-go/v4/pkg/workos_errors" +) + +// ResponseLimit is the default number of records to limit a response to. +const ResponseLimit = 10 + +// Order represents the order of records. +type Order string + +// Constants that enumerate the available orders. +const ( + Asc Order = "asc" + Desc Order = "desc" + CheckOpAllOf = "all_of" + CheckOpAnyOf = "any_of" + CheckOpBatch = "batch" + CheckResultAuthorized = "authorized" + CheckResultNotAuthorized = "not_authorized" + WarrantOpCreate = "create" + WarrantOpDelete = "delete" +) + +// Client represents a client that performs FGA requests to the WorkOS API. +type Client struct { + // The WorkOS API Key. It can be found in https://dashboard.workos.com/api-keys. + APIKey string + + // The http.Client that is used to get FGA records from WorkOS. + // Defaults to http.Client. + HTTPClient *http.Client + + // The endpoint to WorkOS API. Defaults to https://api.workos.com. + Endpoint string + + // The function used to encode in JSON. Defaults to json.Marshal. + JSONEncode func(v interface{}) ([]byte, error) + + once sync.Once +} + +func (c *Client) init() { + if c.HTTPClient == nil { + c.HTTPClient = &http.Client{Timeout: 10 * time.Second} + } + + if c.Endpoint == "" { + c.Endpoint = "https://api.workos.com" + } + + if c.JSONEncode == nil { + c.JSONEncode = json.Marshal + } +} + +// Resources +type Resource struct { + // The type of the resource. + ResourceType string `json:"resource_type"` + + // The customer defined string identifier for this resource. + ResourceId string `json:"resource_id"` + + // Map containing additional information about this resource. + Meta map[string]interface{} `json:"meta"` +} + +type GetResourceOpts struct { + // The type of the resource. + ResourceType string + + // The customer defined string identifier for this resource. + ResourceId string +} + +type ListResourcesOpts struct { + // The type of the resource. + ResourceType string `url:"resource_type,omitempty"` + + // Searchable text for a Resource. Can be empty. + Search string `url:"search,omitempty"` + + // Maximum number of records to return. + Limit int `url:"limit,omitempty"` + + // The order in which to paginate records. + Order Order `url:"order,omitempty"` + + // Pagination cursor to receive records before a provided Resource ID. + Before string `url:"before,omitempty"` + + // Pagination cursor to receive records after a provided Resource ID. + After string `url:"after,omitempty"` +} + +// ListResourcesResponse describes the response structure when requesting Resources +type ListResourcesResponse struct { + // List of provisioned Resources. + Data []Resource `json:"data"` + + // Cursor pagination options. + ListMetadata common.ListMetadata `json:"list_metadata"` +} + +type CreateResourceOpts struct { + // The type of the resource. + ResourceType string `json:"resource_type"` + + // The customer defined string identifier for this resource. + ResourceId string `json:"resource_id,omitempty"` + + // Map containing additional information about this resource. + Meta map[string]interface{} `json:"meta,omitempty"` +} + +type UpdateResourceOpts struct { + // The type of the resource. + ResourceType string `json:"resource_type"` + + // The customer defined string identifier for this resource. + ResourceId string `json:"resource_id"` + + // Map containing additional information about this resource. + Meta map[string]interface{} `json:"meta,omitempty"` +} + +// DeleteResourceOpts contains the options to delete a resource. +type DeleteResourceOpts struct { + // The type of the resource. + ResourceType string + + // The customer defined string identifier for this resource. + ResourceId string +} + +// Resource types +type ResourceType struct { + // Unique string ID of the resource type. + Type string `json:"type"` + + // Set of relationships that subjects can have on resources of this type. + Relations map[string]interface{} `json:"relations"` +} + +type ListResourceTypesOpts struct { + // Maximum number of records to return. + Limit int `url:"limit,omitempty"` + + // The order in which to paginate records. + Order Order `url:"order,omitempty"` + + // Pagination cursor to receive records before a provided ResourceType ID. + Before string `url:"before,omitempty"` + + // Pagination cursor to receive records after a provided ResourceType ID. + After string `url:"after,omitempty"` +} + +type ListResourceTypesResponse struct { + // List of Resource Types. + Data []ResourceType `json:"data"` + + // Cursor pagination options. + ListMetadata common.ListMetadata `json:"list_metadata"` +} + +type UpdateResourceTypeOpts struct { + // Unique string ID of the resource type. + Type string `json:"type"` + + // Set of relationships that subjects can have on resources of this type. + Relations map[string]interface{} `json:"relations"` +} + +// Warrants +type Subject struct { + // The type of the subject. + ResourceType string `json:"resource_type"` + + // The customer defined string identifier for this subject. + ResourceId string `json:"resource_id"` + + // The relation of the subject. + Relation string `json:"relation,omitempty"` +} + +type Warrant struct { + // Type of resource to assign a relation to. Must be an existing type. + ResourceType string `json:"resource_type"` + + // Id of the resource to assign a relation to. + ResourceId string `json:"resource_id"` + + // Relation to assign to the resource. + Relation string `json:"relation"` + + // Subject of the warrant + Subject Subject `json:"subject"` + + // Policy that must evaluate to true for warrant to be valid + Policy string `json:"policy,omitempty"` +} + +type ListWarrantsOpts struct { + // Only return warrants whose resourceType matches this value. + ResourceType string `url:"resource_type,omitempty"` + + // Only return warrants whose resourceId matches this value. + ResourceId string `url:"resource_id,omitempty"` + + // Only return warrants whose relation matches this value. + Relation string `url:"relation,omitempty"` + + // Only return warrants with a subject whose resourceType matches this value. + SubjectType string `url:"subject_type,omitempty"` + + // Only return warrants with a subject whose resourceId matches this value. + SubjectId string `url:"subject_id,omitempty"` + + // Only return warrants with a subject whose relation matches this value. + SubjectRelation string `url:"subject_relation,omitempty"` + + // Maximum number of records to return. + Limit int `url:"limit,omitempty"` + + // Pagination cursor to receive records after a provided Warrant ID. + After string `url:"after,omitempty"` + + // Optional token to specify desired read consistency + WarrantToken string `url:"-"` +} + +// ListWarrantsResponse describes the response structure when requesting Warrants +type ListWarrantsResponse struct { + // List of provisioned Warrants. + Data []Warrant `json:"data"` + + // Cursor pagination options. + ListMetadata common.ListMetadata `json:"list_metadata"` +} + +type WriteWarrantOpts struct { + // Operation to perform for the given warrant + Op string `json:"op,omitempty"` + + // Type of resource to assign a relation to. Must be an existing type. + ResourceType string `json:"resource_type"` + + // Id of the resource to assign a relation to. + ResourceId string `json:"resource_id"` + + // Relation to assign to the resource. + Relation string `json:"relation"` + + // Subject of the warrant + Subject Subject `json:"subject"` + + // Policy that must evaluate to true for warrant to be valid + Policy string `json:"policy,omitempty"` +} + +type WriteWarrantResponse struct { + WarrantToken string `json:"warrant_token"` +} + +// Check +type Context map[string]interface{} + +func (context Context) EncodeValues(key string, values *url.Values) error { + jsonCtx, err := json.Marshal(context) + if err != nil { + return err + } + values.Set(key, string(jsonCtx)) + return nil +} + +type WarrantCheck struct { + // The type of the resource. + ResourceType string `json:"resource_type"` + + // Id of the specific resource. + ResourceId string `json:"resource_id"` + + // Relation to check between the resource and subject. + Relation string `json:"relation"` + + // The subject that must have the specified relation. + Subject Subject `json:"subject"` + + // Contextual data to use for the access check. + Context Context `json:"context,omitempty"` +} + +type CheckOpts struct { + // The operator to use for the given warrants. + Op string `json:"op,omitempty"` + + // List of warrants to check. + Checks []WarrantCheck `json:"checks"` + + // Flag to include debug information in the response. + Debug bool `json:"debug,omitempty"` + + // Optional token to specify desired read consistency + WarrantToken string `json:"-"` +} + +type CheckBatchOpts struct { + // List of warrants to check. + Checks []WarrantCheck `json:"checks"` + + // Flag to include debug information in the response. + Debug bool `json:"debug,omitempty"` + + // Optional token to specify desired read consistency + WarrantToken string `json:"-"` +} + +type CheckResponse struct { + Result string `json:"result"` + IsImplicit bool `json:"is_implicit"` + DebugInfo DebugInfo `json:"debug_info,omitempty"` +} + +func (checkResponse CheckResponse) Authorized() bool { + return checkResponse.Result == CheckResultAuthorized +} + +type DebugInfo struct { + ProcessingTime time.Duration `json:"processing_time"` + DecisionTree *DecisionTreeNode `json:"decision_tree"` +} + +type DecisionTreeNode struct { + Check WarrantCheck `json:"check"` + Policy string `json:"policy,omitempty"` + Decision string `json:"decision"` + ProcessingTime time.Duration `json:"processing_time"` + Children []DecisionTreeNode `json:"children"` +} + +// Query +type QueryOpts struct { + // Query to be executed. + Query string `url:"q"` + + // Contextual data to use for the query. + Context Context `url:"context,omitempty"` + + // Maximum number of records to return. + Limit int `url:"limit,omitempty"` + + // The order in which to paginate records. + Order Order `url:"order,omitempty"` + + // Pagination cursor to receive records before a provided Warrant ID. + Before string `url:"before,omitempty"` + + // Pagination cursor to receive records after a provided Warrant ID. + After string `url:"after,omitempty"` + + // Optional token to specify desired read consistency + WarrantToken string `url:"-"` +} + +type QueryResult struct { + // The type of the resource. + ResourceType string `json:"resource_type"` + + // Id of the specific resource. + ResourceId string `json:"resource_id"` + + // Relation between the resource and subject. + Relation string `json:"relation"` + + // Warrant matching the provided query + Warrant Warrant `json:"warrant"` + + // Specifies whether the warrant is implicitly defined. + IsImplicit bool `json:"is_implicit"` + + // Metadata of the resource. + Meta map[string]interface{} `json:"meta,omitempty"` +} + +type QueryResponse struct { + // List of query results. + Data []QueryResult `json:"data"` + + // Cursor pagination options. + ListMetadata common.ListMetadata `json:"list_metadata"` +} + +// GetResource gets a Resource. +func (c *Client) GetResource(ctx context.Context, opts GetResourceOpts) (Resource, error) { + c.once.Do(c.init) + + endpoint := fmt.Sprintf("%s/fga/v1/resources/%s/%s", c.Endpoint, opts.ResourceType, opts.ResourceId) + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return Resource{}, err + } + + req = req.WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.APIKey) + req.Header.Set("User-Agent", "workos-go/"+workos.Version) + + res, err := c.HTTPClient.Do(req) + if err != nil { + return Resource{}, err + } + defer res.Body.Close() + + if err = workos_errors.TryGetHTTPError(res); err != nil { + return Resource{}, err + } + + var body Resource + dec := json.NewDecoder(res.Body) + err = dec.Decode(&body) + return body, err +} + +// ListResources gets a list of FGA resources. +func (c *Client) ListResources(ctx context.Context, opts ListResourcesOpts) (ListResourcesResponse, error) { + c.once.Do(c.init) + + endpoint := fmt.Sprintf("%s/fga/v1/resources", c.Endpoint) + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return ListResourcesResponse{}, err + } + + req = req.WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.APIKey) + req.Header.Set("User-Agent", "workos-go/"+workos.Version) + + if opts.Limit == 0 { + opts.Limit = ResponseLimit + } + + if opts.Order == "" { + opts.Order = Desc + } + + q, err := query.Values(opts) + if err != nil { + return ListResourcesResponse{}, err + } + + req.URL.RawQuery = q.Encode() + + res, err := c.HTTPClient.Do(req) + if err != nil { + return ListResourcesResponse{}, err + } + defer res.Body.Close() + + if err = workos_errors.TryGetHTTPError(res); err != nil { + return ListResourcesResponse{}, err + } + + var body ListResourcesResponse + dec := json.NewDecoder(res.Body) + err = dec.Decode(&body) + return body, err +} + +// CreateResource creates a new resource +func (c *Client) CreateResource(ctx context.Context, opts CreateResourceOpts) (Resource, error) { + c.once.Do(c.init) + + data, err := c.JSONEncode(opts) + if err != nil { + return Resource{}, err + } + + endpoint := fmt.Sprintf("%s/fga/v1/resources", c.Endpoint) + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(data)) + if err != nil { + return Resource{}, err + } + + req = req.WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.APIKey) + req.Header.Set("User-Agent", "workos-go/"+workos.Version) + + res, err := c.HTTPClient.Do(req) + if err != nil { + return Resource{}, err + } + defer res.Body.Close() + + if err = workos_errors.TryGetHTTPError(res); err != nil { + return Resource{}, err + } + + var body Resource + dec := json.NewDecoder(res.Body) + err = dec.Decode(&body) + return body, err +} + +// UpdateResource updates an existing Resource +func (c *Client) UpdateResource(ctx context.Context, opts UpdateResourceOpts) (Resource, error) { + c.once.Do(c.init) + + // UpdateResourceChangeOpts contains the options to update a Resource minus the ResourceType and ResourceId + type UpdateResourceChangeOpts struct { + Meta map[string]interface{} `json:"meta"` + } + + update_opts := UpdateResourceChangeOpts{Meta: opts.Meta} + + data, err := c.JSONEncode(update_opts) + if err != nil { + return Resource{}, err + } + + endpoint := fmt.Sprintf("%s/fga/v1/resources/%s/%s", c.Endpoint, opts.ResourceType, opts.ResourceId) + req, err := http.NewRequest(http.MethodPut, endpoint, bytes.NewBuffer(data)) + if err != nil { + return Resource{}, err + } + + req = req.WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.APIKey) + req.Header.Set("User-Agent", "workos-go/"+workos.Version) + + res, err := c.HTTPClient.Do(req) + if err != nil { + return Resource{}, err + } + defer res.Body.Close() + + if err = workos_errors.TryGetHTTPError(res); err != nil { + return Resource{}, err + } + + var body Resource + dec := json.NewDecoder(res.Body) + err = dec.Decode(&body) + return body, err + +} + +// DeleteResource deletes a Resource +func (c *Client) DeleteResource(ctx context.Context, opts DeleteResourceOpts) error { + c.once.Do(c.init) + + endpoint := fmt.Sprintf("%s/fga/v1/resources/%s/%s", c.Endpoint, opts.ResourceType, opts.ResourceId) + req, err := http.NewRequest(http.MethodDelete, endpoint, nil) + if err != nil { + return err + } + + req = req.WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.APIKey) + req.Header.Set("User-Agent", "workos-go/"+workos.Version) + + res, err := c.HTTPClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + return workos_errors.TryGetHTTPError(res) +} + +// ListResourceTypes gets a list of FGA resource types. +func (c *Client) ListResourceTypes(ctx context.Context, opts ListResourceTypesOpts) (ListResourceTypesResponse, error) { + c.once.Do(c.init) + + endpoint := fmt.Sprintf("%s/fga/v1/resource-types", c.Endpoint) + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return ListResourceTypesResponse{}, err + } + + req = req.WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.APIKey) + req.Header.Set("User-Agent", "workos-go/"+workos.Version) + + if opts.Limit == 0 { + opts.Limit = ResponseLimit + } + + if opts.Order == "" { + opts.Order = Desc + } + + q, err := query.Values(opts) + if err != nil { + return ListResourceTypesResponse{}, err + } + + req.URL.RawQuery = q.Encode() + + res, err := c.HTTPClient.Do(req) + if err != nil { + return ListResourceTypesResponse{}, err + } + defer res.Body.Close() + + if err = workos_errors.TryGetHTTPError(res); err != nil { + return ListResourceTypesResponse{}, err + } + + var body ListResourceTypesResponse + dec := json.NewDecoder(res.Body) + err = dec.Decode(&body) + return body, err +} + +// BatchUpdateResourceTypes sets the environment's set of resource types to match the resource types passed. +func (c *Client) BatchUpdateResourceTypes(ctx context.Context, opts []UpdateResourceTypeOpts) ([]ResourceType, error) { + c.once.Do(c.init) + + data, err := c.JSONEncode(opts) + if err != nil { + return []ResourceType{}, err + } + + endpoint := fmt.Sprintf("%s/fga/v1/resource-types", c.Endpoint) + req, err := http.NewRequest(http.MethodPut, endpoint, bytes.NewBuffer(data)) + if err != nil { + return []ResourceType{}, err + } + + req = req.WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.APIKey) + req.Header.Set("User-Agent", "workos-go/"+workos.Version) + + res, err := c.HTTPClient.Do(req) + if err != nil { + return []ResourceType{}, err + } + defer res.Body.Close() + + if err = workos_errors.TryGetHTTPError(res); err != nil { + return []ResourceType{}, err + } + + var body []ResourceType + dec := json.NewDecoder(res.Body) + err = dec.Decode(&body) + return body, err +} + +// ListWarrants gets a list of Warrants. +func (c *Client) ListWarrants(ctx context.Context, opts ListWarrantsOpts) (ListWarrantsResponse, error) { + c.once.Do(c.init) + + endpoint := fmt.Sprintf("%s/fga/v1/warrants", c.Endpoint) + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return ListWarrantsResponse{}, err + } + + req = req.WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.APIKey) + req.Header.Set("User-Agent", "workos-go/"+workos.Version) + if opts.WarrantToken != "" { + req.Header.Set("Warrant-Token", opts.WarrantToken) + } + + if opts.Limit == 0 { + opts.Limit = ResponseLimit + } + + q, err := query.Values(opts) + if err != nil { + return ListWarrantsResponse{}, err + } + + req.URL.RawQuery = q.Encode() + + res, err := c.HTTPClient.Do(req) + if err != nil { + return ListWarrantsResponse{}, err + } + defer res.Body.Close() + + if err = workos_errors.TryGetHTTPError(res); err != nil { + return ListWarrantsResponse{}, err + } + + var body ListWarrantsResponse + dec := json.NewDecoder(res.Body) + err = dec.Decode(&body) + return body, err +} + +// WriteWarrant performs a write operation on a Warrant. +func (c *Client) WriteWarrant(ctx context.Context, opts WriteWarrantOpts) (WriteWarrantResponse, error) { + c.once.Do(c.init) + + data, err := c.JSONEncode(opts) + if err != nil { + return WriteWarrantResponse{}, err + } + + endpoint := fmt.Sprintf("%s/fga/v1/warrants", c.Endpoint) + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(data)) + if err != nil { + return WriteWarrantResponse{}, err + } + + req = req.WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.APIKey) + req.Header.Set("User-Agent", "workos-go/"+workos.Version) + + res, err := c.HTTPClient.Do(req) + if err != nil { + return WriteWarrantResponse{}, err + } + defer res.Body.Close() + + if err = workos_errors.TryGetHTTPError(res); err != nil { + return WriteWarrantResponse{}, err + } + + var body WriteWarrantResponse + dec := json.NewDecoder(res.Body) + err = dec.Decode(&body) + return body, err +} + +// BatchWriteWarrants performs a write operation on a Warrant. +func (c *Client) BatchWriteWarrants(ctx context.Context, opts []WriteWarrantOpts) (WriteWarrantResponse, error) { + c.once.Do(c.init) + + data, err := c.JSONEncode(opts) + if err != nil { + return WriteWarrantResponse{}, err + } + + endpoint := fmt.Sprintf("%s/fga/v1/warrants", c.Endpoint) + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(data)) + if err != nil { + return WriteWarrantResponse{}, err + } + + req = req.WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.APIKey) + req.Header.Set("User-Agent", "workos-go/"+workos.Version) + + res, err := c.HTTPClient.Do(req) + if err != nil { + return WriteWarrantResponse{}, err + } + defer res.Body.Close() + + if err = workos_errors.TryGetHTTPError(res); err != nil { + return WriteWarrantResponse{}, err + } + + var body WriteWarrantResponse + dec := json.NewDecoder(res.Body) + err = dec.Decode(&body) + return body, err +} + +func (c *Client) Check(ctx context.Context, opts CheckOpts) (CheckResponse, error) { + c.once.Do(c.init) + + data, err := c.JSONEncode(opts) + if err != nil { + return CheckResponse{}, err + } + + endpoint := fmt.Sprintf("%s/fga/v1/check", c.Endpoint) + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(data)) + if err != nil { + return CheckResponse{}, err + } + + req = req.WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.APIKey) + req.Header.Set("User-Agent", "workos-go/"+workos.Version) + if opts.WarrantToken != "" { + req.Header.Set("Warrant-Token", opts.WarrantToken) + } + + res, err := c.HTTPClient.Do(req) + if err != nil { + return CheckResponse{}, err + } + defer res.Body.Close() + + if err = workos_errors.TryGetHTTPError(res); err != nil { + return CheckResponse{}, err + } + + var checkResponse CheckResponse + dec := json.NewDecoder(res.Body) + err = dec.Decode(&checkResponse) + if err != nil { + return CheckResponse{}, err + } + + return checkResponse, nil +} + +func (c *Client) CheckBatch(ctx context.Context, opts CheckBatchOpts) ([]CheckResponse, error) { + c.once.Do(c.init) + + checkOpts := CheckOpts{ + Op: CheckOpBatch, + Checks: opts.Checks, + Debug: opts.Debug, + WarrantToken: opts.WarrantToken, + } + data, err := c.JSONEncode(checkOpts) + if err != nil { + return []CheckResponse{}, err + } + + endpoint := fmt.Sprintf("%s/fga/v1/check", c.Endpoint) + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(data)) + if err != nil { + return []CheckResponse{}, err + } + + req = req.WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.APIKey) + req.Header.Set("User-Agent", "workos-go/"+workos.Version) + if opts.WarrantToken != "" { + req.Header.Set("Warrant-Token", opts.WarrantToken) + } + + res, err := c.HTTPClient.Do(req) + if err != nil { + return []CheckResponse{}, err + } + defer res.Body.Close() + + if err = workos_errors.TryGetHTTPError(res); err != nil { + return []CheckResponse{}, err + } + + var checkResponses []CheckResponse + dec := json.NewDecoder(res.Body) + err = dec.Decode(&checkResponses) + if err != nil { + return []CheckResponse{}, err + } + + return checkResponses, nil +} + +// Query executes a query for a set of resources. +func (c *Client) Query(ctx context.Context, opts QueryOpts) (QueryResponse, error) { + c.once.Do(c.init) + + endpoint := fmt.Sprintf("%s/fga/v1/query", c.Endpoint) + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return QueryResponse{}, err + } + + req = req.WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.APIKey) + req.Header.Set("User-Agent", "workos-go/"+workos.Version) + if opts.WarrantToken != "" { + req.Header.Set("Warrant-Token", opts.WarrantToken) + } + + if opts.Limit == 0 { + opts.Limit = ResponseLimit + } + + if opts.Order == "" { + opts.Order = Desc + } + + q, err := query.Values(opts) + if err != nil { + return QueryResponse{}, err + } + + req.URL.RawQuery = q.Encode() + + res, err := c.HTTPClient.Do(req) + if err != nil { + return QueryResponse{}, err + } + defer res.Body.Close() + + if err = workos_errors.TryGetHTTPError(res); err != nil { + return QueryResponse{}, err + } + + var body QueryResponse + dec := json.NewDecoder(res.Body) + err = dec.Decode(&body) + return body, err +} diff --git a/pkg/fga/client_live_example.go b/pkg/fga/client_live_example.go new file mode 100644 index 00000000..a99b43de --- /dev/null +++ b/pkg/fga/client_live_example.go @@ -0,0 +1,1894 @@ +package fga + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func setup() { + SetAPIKey("") +} + +func TestCrudResources(t *testing.T) { + setup() + + resource1, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "document", + }) + if err != nil { + t.Fatal(err) + } + require.Equal(t, "document", resource1.ResourceType) + require.NotEmpty(t, resource1.ResourceId) + require.Empty(t, resource1.Meta) + + resource2, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "folder", + ResourceId: "planning", + }) + if err != nil { + t.Fatal(err) + } + refetchedResource, err := GetResource(context.Background(), GetResourceOpts{ + ResourceType: resource2.ResourceType, + ResourceId: resource2.ResourceId, + }) + if err != nil { + t.Fatal(err) + } + require.Equal(t, resource2.ResourceType, refetchedResource.ResourceType) + require.Equal(t, resource2.ResourceId, refetchedResource.ResourceId) + require.EqualValues(t, resource2.Meta, refetchedResource.Meta) + + resource2, err = UpdateResource(context.Background(), UpdateResourceOpts{ + ResourceType: resource2.ResourceType, + ResourceId: resource2.ResourceId, + Meta: map[string]interface{}{ + "description": "Folder resource", + }, + }) + if err != nil { + t.Fatal(err) + } + refetchedResource, err = GetResource(context.Background(), GetResourceOpts{ + ResourceType: resource2.ResourceType, + ResourceId: resource2.ResourceId, + }) + if err != nil { + t.Fatal(err) + } + require.Equal(t, resource2.ResourceType, refetchedResource.ResourceType) + require.Equal(t, resource2.ResourceId, refetchedResource.ResourceId) + require.EqualValues(t, resource2.Meta, refetchedResource.Meta) + + resourcesList, err := ListResources(context.Background(), ListResourcesOpts{ + Limit: 10, + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, resourcesList.Data, 2) + require.Equal(t, resource2.ResourceType, resourcesList.Data[0].ResourceType) + require.Equal(t, resource2.ResourceId, resourcesList.Data[0].ResourceId) + require.Equal(t, resource1.ResourceType, resourcesList.Data[1].ResourceType) + require.Equal(t, resource1.ResourceId, resourcesList.Data[1].ResourceId) + + // Sort in ascending order + resourcesList, err = ListResources(context.Background(), ListResourcesOpts{ + Limit: 10, + Order: Asc, + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, resourcesList.Data, 2) + require.Equal(t, resource1.ResourceType, resourcesList.Data[0].ResourceType) + require.Equal(t, resource1.ResourceId, resourcesList.Data[0].ResourceId) + require.Equal(t, resource2.ResourceType, resourcesList.Data[1].ResourceType) + require.Equal(t, resource2.ResourceId, resourcesList.Data[1].ResourceId) + + resourcesList, err = ListResources(context.Background(), ListResourcesOpts{ + Limit: 10, + Search: "planning", + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, resourcesList.Data, 1) + require.Equal(t, resource2.ResourceType, resourcesList.Data[0].ResourceType) + require.Equal(t, resource2.ResourceId, resourcesList.Data[0].ResourceId) + + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: resource1.ResourceType, + ResourceId: resource1.ResourceId, + }) + if err != nil { + t.Fatal(err) + } + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: resource2.ResourceType, + ResourceId: resource2.ResourceId, + }) + if err != nil { + t.Fatal(err) + } + resourcesList, err = ListResources(context.Background(), ListResourcesOpts{ + Limit: 10, + Search: "planning", + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, resourcesList.Data, 0) +} + +func TestMultiTenancy(t *testing.T) { + setup() + + // Create users + user1, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "user", + }) + if err != nil { + t.Fatal(err) + } + user2, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "user", + }) + if err != nil { + t.Fatal(err) + } + + // Create tenants + tenant1, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "tenant", + ResourceId: "tenant-1", + Meta: map[string]interface{}{ + "name": "Tenant 1", + }, + }) + if err != nil { + t.Fatal(err) + } + tenant2, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "tenant", + ResourceId: "tenant-2", + Meta: map[string]interface{}{ + "name": "Tenant 2", + }, + }) + if err != nil { + t.Fatal(err) + } + + user1TenantsList, err := Query(context.Background(), QueryOpts{ + Query: fmt.Sprintf("select tenant where user:%s is member", user1.ResourceId), + Limit: 10, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, user1TenantsList.Data, 0) + tenant1UsersList, err := Query(context.Background(), QueryOpts{ + Query: fmt.Sprintf("select member of type user for tenant:%s", tenant1.ResourceId), + Limit: 10, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, tenant1UsersList.Data, 0) + + // Assign user1 -> tenant1 + warrantResponse, err := WriteWarrant(context.Background(), WriteWarrantOpts{ + ResourceType: tenant1.ResourceType, + ResourceId: tenant1.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: user1.ResourceType, + ResourceId: user1.ResourceId, + }, + }) + if err != nil { + t.Fatal(err) + } + require.NotEmpty(t, warrantResponse.WarrantToken) + + user1TenantsList, err = Query(context.Background(), QueryOpts{ + Query: fmt.Sprintf("select tenant where user:%s is member", user1.ResourceId), + Limit: 10, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, user1TenantsList.Data, 1) + require.Equal(t, "tenant", user1TenantsList.Data[0].ResourceType) + require.Equal(t, "tenant-1", user1TenantsList.Data[0].ResourceId) + require.EqualValues(t, map[string]interface{}{ + "name": "Tenant 1", + }, user1TenantsList.Data[0].Meta) + + tenant1UsersList, err = Query(context.Background(), QueryOpts{ + Query: fmt.Sprintf("select member of type user for tenant:%s", tenant1.ResourceId), + Limit: 10, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, tenant1UsersList.Data, 1) + require.Equal(t, "user", tenant1UsersList.Data[0].ResourceType) + require.Equal(t, user1.ResourceId, tenant1UsersList.Data[0].ResourceId) + require.Empty(t, tenant1UsersList.Data[0].Meta) + + // Remove user1 -> tenant1 + warrantResponse, err = WriteWarrant(context.Background(), WriteWarrantOpts{ + Op: "delete", + ResourceType: tenant1.ResourceType, + ResourceId: tenant1.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: user1.ResourceType, + ResourceId: user1.ResourceId, + }, + }) + if err != nil { + t.Fatal(err) + } + require.NotEmpty(t, warrantResponse.WarrantToken) + + user1TenantsList, err = Query(context.Background(), QueryOpts{ + Query: fmt.Sprintf("select tenant where user:%s is member", user1.ResourceId), + Limit: 10, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, user1TenantsList.Data, 0) + tenant1UsersList, err = Query(context.Background(), QueryOpts{ + Query: fmt.Sprintf("select member of type user for tenant:%s", tenant1.ResourceId), + Limit: 10, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, tenant1UsersList.Data, 0) + + // Clean up + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: user1.ResourceType, + ResourceId: user1.ResourceId, + }) + if err != nil { + t.Fatal(err) + } + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: user2.ResourceType, + ResourceId: user2.ResourceId, + }) + if err != nil { + t.Fatal(err) + } + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: tenant1.ResourceType, + ResourceId: tenant1.ResourceId, + }) + if err != nil { + t.Fatal(err) + } + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: tenant2.ResourceType, + ResourceId: tenant2.ResourceId, + }) + if err != nil { + t.Fatal(err) + } +} + +func TestRBAC(t *testing.T) { + setup() + + // Create users + adminUser, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "user", + }) + if err != nil { + t.Fatal(err) + } + viewerUser, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "user", + }) + if err != nil { + t.Fatal(err) + } + + // Create roles + adminRole, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "role", + ResourceId: "administrator", + Meta: map[string]interface{}{ + "name": "Administrator", + "description": "The admin role", + }, + }) + if err != nil { + t.Fatal(err) + } + viewerRole, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "role", + ResourceId: "viewer", + Meta: map[string]interface{}{ + "name": "Viewer", + "description": "The viewer role", + }, + }) + if err != nil { + t.Fatal(err) + } + + // Create permissions + createPermission, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "permission", + ResourceId: "create-report", + Meta: map[string]interface{}{ + "name": "Create Report", + "description": "Permission to create reports", + }, + }) + if err != nil { + t.Fatal(err) + } + viewPermission, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "permission", + ResourceId: "view-report", + Meta: map[string]interface{}{ + "name": "View Report", + "description": "Permission to view reports", + }, + }) + if err != nil { + t.Fatal(err) + } + + adminUserRolesList, err := Query(context.Background(), QueryOpts{ + Query: fmt.Sprintf("select role where user:%s is member", adminUser.ResourceId), + Limit: 10, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, adminUserRolesList.Data, 0) + + adminRolePermissionsList, err := Query(context.Background(), QueryOpts{ + Query: fmt.Sprintf("select permission where role:%s is member", adminRole.ResourceId), + Limit: 10, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, adminRolePermissionsList.Data, 0) + + adminUserHasPermission, err := Check(context.Background(), CheckOpts{ + Checks: []WarrantCheck{ + { + ResourceType: createPermission.ResourceType, + ResourceId: createPermission.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: adminUser.ResourceType, + ResourceId: adminUser.ResourceId, + }, + }, + }, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.False(t, adminUserHasPermission.Authorized()) + + // Assign create-report permission -> admin role -> admin user + warrantResponse, err := WriteWarrant(context.Background(), WriteWarrantOpts{ + ResourceType: createPermission.ResourceType, + ResourceId: createPermission.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: adminRole.ResourceType, + ResourceId: adminRole.ResourceId, + }, + }) + if err != nil { + t.Fatal(err) + } + require.NotEmpty(t, warrantResponse.WarrantToken) + + warrantResponse, err = WriteWarrant(context.Background(), WriteWarrantOpts{ + ResourceType: adminRole.ResourceType, + ResourceId: adminRole.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: adminUser.ResourceType, + ResourceId: adminUser.ResourceId, + }, + }) + if err != nil { + t.Fatal(err) + } + require.NotEmpty(t, warrantResponse.WarrantToken) + + adminUserHasPermission, err = Check(context.Background(), CheckOpts{ + Checks: []WarrantCheck{ + { + ResourceType: createPermission.ResourceType, + ResourceId: createPermission.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: adminUser.ResourceType, + ResourceId: adminUser.ResourceId, + }, + }, + }, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.True(t, adminUserHasPermission.Authorized()) + + adminUserRolesList, err = Query(context.Background(), QueryOpts{ + Query: fmt.Sprintf("select role where user:%s is member", adminUser.ResourceId), + Limit: 10, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, adminUserRolesList.Data, 1) + require.Equal(t, "role", adminUserRolesList.Data[0].ResourceType) + require.Equal(t, adminRole.ResourceId, adminUserRolesList.Data[0].ResourceId) + require.Equal(t, map[string]interface{}{ + "name": "Administrator", + "description": "The admin role", + }, adminUserRolesList.Data[0].Meta) + + adminRolePermissionsList, err = Query(context.Background(), QueryOpts{ + Query: fmt.Sprintf("select permission where role:%s is member", adminRole.ResourceId), + Limit: 10, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, adminRolePermissionsList.Data, 1) + require.Equal(t, "permission", adminRolePermissionsList.Data[0].ResourceType) + require.Equal(t, createPermission.ResourceId, adminRolePermissionsList.Data[0].ResourceId) + require.Equal(t, map[string]interface{}{ + "name": "Create Report", + "description": "Permission to create reports", + }, adminRolePermissionsList.Data[0].Meta) + + // Remove create-report permission -> admin role -> admin user + warrantResponse, err = WriteWarrant(context.Background(), WriteWarrantOpts{ + Op: "delete", + ResourceType: createPermission.ResourceType, + ResourceId: createPermission.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: adminRole.ResourceType, + ResourceId: adminRole.ResourceId, + }, + }) + if err != nil { + t.Fatal(err) + } + require.NotEmpty(t, warrantResponse.WarrantToken) + + warrantResponse, err = WriteWarrant(context.Background(), WriteWarrantOpts{ + Op: "delete", + ResourceType: adminRole.ResourceType, + ResourceId: adminRole.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: adminUser.ResourceType, + ResourceId: adminUser.ResourceId, + }, + }) + if err != nil { + t.Fatal(err) + } + require.NotEmpty(t, warrantResponse.WarrantToken) + + adminUserHasPermission, err = Check(context.Background(), CheckOpts{ + Checks: []WarrantCheck{ + { + ResourceType: createPermission.ResourceType, + ResourceId: createPermission.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: adminUser.ResourceType, + ResourceId: adminUser.ResourceId, + }, + }, + }, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.False(t, adminUserHasPermission.Authorized()) + + adminUserRolesList, err = Query(context.Background(), QueryOpts{ + Query: fmt.Sprintf("select role where user:%s is member", adminUser.ResourceId), + Limit: 10, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, adminUserRolesList.Data, 0) + + adminRolePermissionsList, err = Query(context.Background(), QueryOpts{ + Query: fmt.Sprintf("select permission where role:%s is member", adminRole.ResourceId), + Limit: 10, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, adminRolePermissionsList.Data, 0) + + // Assign view-report -> viewer user + viewerUserHasPermission, err := Check(context.Background(), CheckOpts{ + Checks: []WarrantCheck{ + { + ResourceType: viewPermission.ResourceType, + ResourceId: viewPermission.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: viewerUser.ResourceType, + ResourceId: viewerUser.ResourceId, + }, + }, + }, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.False(t, viewerUserHasPermission.Authorized()) + + viewerUserPermissionsList, err := Query(context.Background(), QueryOpts{ + Query: fmt.Sprintf("select permission where user:%s is member", viewerUser.ResourceId), + Limit: 10, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Empty(t, viewerUserPermissionsList.Data) + + warrantResponse, err = WriteWarrant(context.Background(), WriteWarrantOpts{ + ResourceType: viewPermission.ResourceType, + ResourceId: viewPermission.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: viewerUser.ResourceType, + ResourceId: viewerUser.ResourceId, + }, + }) + if err != nil { + t.Fatal(err) + } + require.NotEmpty(t, warrantResponse.WarrantToken) + + viewerUserHasPermission, err = Check(context.Background(), CheckOpts{ + Checks: []WarrantCheck{ + { + ResourceType: viewPermission.ResourceType, + ResourceId: viewPermission.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: viewerUser.ResourceType, + ResourceId: viewerUser.ResourceId, + }, + }, + }, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.True(t, viewerUserHasPermission.Authorized()) + + viewerUserPermissionsList, err = Query(context.Background(), QueryOpts{ + Query: fmt.Sprintf("select permission where user:%s is member", viewerUser.ResourceId), + Limit: 10, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, viewerUserPermissionsList.Data, 1) + require.Equal(t, "permission", viewerUserPermissionsList.Data[0].ResourceType) + require.Equal(t, viewPermission.ResourceId, viewerUserPermissionsList.Data[0].ResourceId) + require.Equal(t, map[string]interface{}{ + "name": "View Report", + "description": "Permission to view reports", + }, viewerUserPermissionsList.Data[0].Meta) + + warrantResponse, err = WriteWarrant(context.Background(), WriteWarrantOpts{ + Op: "delete", + ResourceType: viewPermission.ResourceType, + ResourceId: viewPermission.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: viewerUser.ResourceType, + ResourceId: viewerUser.ResourceId, + }, + }) + if err != nil { + t.Fatal(err) + } + require.NotEmpty(t, warrantResponse.WarrantToken) + + viewerUserHasPermission, err = Check(context.Background(), CheckOpts{ + Checks: []WarrantCheck{ + { + ResourceType: viewPermission.ResourceType, + ResourceId: viewPermission.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: viewerUser.ResourceType, + ResourceId: viewerUser.ResourceId, + }, + }, + }, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.False(t, viewerUserHasPermission.Authorized()) + + viewerUserPermissionsList, err = Query(context.Background(), QueryOpts{ + Query: fmt.Sprintf("select permission where user:%s is member", viewerUser.ResourceId), + Limit: 10, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Empty(t, viewerUserPermissionsList.Data) + + // Clean up + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: adminUser.ResourceType, + ResourceId: adminUser.ResourceId, + }) + if err != nil { + t.Fatal(err) + } + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: viewerUser.ResourceType, + ResourceId: viewerUser.ResourceId, + }) + if err != nil { + t.Fatal(err) + } + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: adminRole.ResourceType, + ResourceId: adminRole.ResourceId, + }) + if err != nil { + t.Fatal(err) + } + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: viewerRole.ResourceType, + ResourceId: viewerRole.ResourceId, + }) + if err != nil { + t.Fatal(err) + } + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: createPermission.ResourceType, + ResourceId: createPermission.ResourceId, + }) + if err != nil { + t.Fatal(err) + } + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: viewPermission.ResourceType, + ResourceId: viewPermission.ResourceId, + }) + if err != nil { + t.Fatal(err) + } +} + +func TestPricingTiersFeaturesAndUsers(t *testing.T) { + setup() + + // Create users + freeUser, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "user", + }) + if err != nil { + t.Fatal(err) + } + paidUser, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "user", + }) + if err != nil { + t.Fatal(err) + } + + // Create pricing tiers + freeTier, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "pricing-tier", + ResourceId: "free", + Meta: map[string]interface{}{ + "name": "Free Tier", + }, + }) + if err != nil { + t.Fatal(err) + } + paidTier, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "pricing-tier", + ResourceId: "paid", + }) + if err != nil { + t.Fatal(err) + } + + // Create features + customFeature, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "feature", + ResourceId: "custom", + Meta: map[string]interface{}{ + "name": "Custom Feature", + }, + }) + if err != nil { + t.Fatal(err) + } + feature1, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "feature", + ResourceId: "feature-1", + }) + if err != nil { + t.Fatal(err) + } + feature2, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "feature", + ResourceId: "feature-2", + }) + if err != nil { + t.Fatal(err) + } + + // Assign custom-feature -> paid user + paidUserHasFeature, err := Check(context.Background(), CheckOpts{ + Checks: []WarrantCheck{ + { + ResourceType: customFeature.ResourceType, + ResourceId: customFeature.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: paidUser.ResourceType, + ResourceId: paidUser.ResourceId, + }, + }, + }, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.False(t, paidUserHasFeature.Authorized()) + + paidUserFeaturesList, err := Query(context.Background(), QueryOpts{ + Query: fmt.Sprintf("select feature where user:%s is member", paidUser.ResourceId), + Limit: 10, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Empty(t, paidUserFeaturesList.Data) + + warrantResponse, err := WriteWarrant(context.Background(), WriteWarrantOpts{ + ResourceType: customFeature.ResourceType, + ResourceId: customFeature.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: paidUser.ResourceType, + ResourceId: paidUser.ResourceId, + }, + }) + if err != nil { + t.Fatal(err) + } + require.NotEmpty(t, warrantResponse.WarrantToken) + + paidUserHasFeature, err = Check(context.Background(), CheckOpts{ + Checks: []WarrantCheck{ + { + ResourceType: customFeature.ResourceType, + ResourceId: customFeature.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: paidUser.ResourceType, + ResourceId: paidUser.ResourceId, + }, + }, + }, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.True(t, paidUserHasFeature.Authorized()) + + paidUserFeaturesList, err = Query(context.Background(), QueryOpts{ + Query: fmt.Sprintf("select feature where user:%s is member", paidUser.ResourceId), + Limit: 10, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, paidUserFeaturesList.Data, 1) + require.Equal(t, "feature", paidUserFeaturesList.Data[0].ResourceType) + require.Equal(t, customFeature.ResourceId, paidUserFeaturesList.Data[0].ResourceId) + require.Equal(t, map[string]interface{}{ + "name": "Custom Feature", + }, paidUserFeaturesList.Data[0].Meta) + + warrantResponse, err = WriteWarrant(context.Background(), WriteWarrantOpts{ + Op: "delete", + ResourceType: customFeature.ResourceType, + ResourceId: customFeature.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: paidUser.ResourceType, + ResourceId: paidUser.ResourceId, + }, + }) + if err != nil { + t.Fatal(err) + } + require.NotEmpty(t, warrantResponse.WarrantToken) + + paidUserHasFeature, err = Check(context.Background(), CheckOpts{ + Checks: []WarrantCheck{ + { + ResourceType: customFeature.ResourceType, + ResourceId: customFeature.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: paidUser.ResourceType, + ResourceId: paidUser.ResourceId, + }, + }, + }, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.False(t, paidUserHasFeature.Authorized()) + + paidUserFeaturesList, err = Query(context.Background(), QueryOpts{ + Query: fmt.Sprintf("select feature where user:%s is member", paidUser.ResourceId), + Limit: 10, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Empty(t, paidUserFeaturesList.Data) + + // Assign feature-1 -> free tier -> free user + freeUserHasFeature, err := Check(context.Background(), CheckOpts{ + Checks: []WarrantCheck{ + { + ResourceType: feature1.ResourceType, + ResourceId: feature1.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: freeUser.ResourceType, + ResourceId: freeUser.ResourceId, + }, + }, + }, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.False(t, freeUserHasFeature.Authorized()) + + freeUserFeaturesList, err := Query(context.Background(), QueryOpts{ + Query: fmt.Sprintf("select feature where user:%s is member", freeUser.ResourceId), + Limit: 10, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Empty(t, freeUserFeaturesList.Data) + + featureUserTiersList, err := Query(context.Background(), QueryOpts{ + Query: fmt.Sprintf("select pricing-tier where user:%s is member", freeUser.ResourceId), + Limit: 10, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Empty(t, featureUserTiersList.Data) + + warrantResponse, err = WriteWarrant(context.Background(), WriteWarrantOpts{ + ResourceType: feature1.ResourceType, + ResourceId: feature1.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: freeTier.ResourceType, + ResourceId: freeTier.ResourceId, + }, + }) + if err != nil { + t.Fatal(err) + } + require.NotEmpty(t, warrantResponse.WarrantToken) + + warrantResponse, err = WriteWarrant(context.Background(), WriteWarrantOpts{ + ResourceType: freeTier.ResourceType, + ResourceId: freeTier.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: freeUser.ResourceType, + ResourceId: freeUser.ResourceId, + }, + }) + if err != nil { + t.Fatal(err) + } + require.NotEmpty(t, warrantResponse.WarrantToken) + + freeUserHasFeature, err = Check(context.Background(), CheckOpts{ + Checks: []WarrantCheck{ + { + ResourceType: feature1.ResourceType, + ResourceId: feature1.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: freeUser.ResourceType, + ResourceId: freeUser.ResourceId, + }, + }, + }, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.True(t, freeUserHasFeature.Authorized()) + + freeUserFeaturesList, err = Query(context.Background(), QueryOpts{ + Query: fmt.Sprintf("select feature where user:%s is member", freeUser.ResourceId), + Limit: 10, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, freeUserFeaturesList.Data, 1) + require.Equal(t, "feature", freeUserFeaturesList.Data[0].ResourceType) + require.Equal(t, feature1.ResourceId, freeUserFeaturesList.Data[0].ResourceId) + require.Empty(t, freeUserFeaturesList.Data[0].Meta) + + featureUserTiersList, err = Query(context.Background(), QueryOpts{ + Query: fmt.Sprintf("select pricing-tier where user:%s is member", freeUser.ResourceId), + Limit: 10, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, featureUserTiersList.Data, 1) + require.Equal(t, "pricing-tier", featureUserTiersList.Data[0].ResourceType) + require.Equal(t, freeTier.ResourceId, featureUserTiersList.Data[0].ResourceId) + require.Equal(t, map[string]interface{}{ + "name": "Free Tier", + }, featureUserTiersList.Data[0].Meta) + + warrantResponse, err = WriteWarrant(context.Background(), WriteWarrantOpts{ + Op: "delete", + ResourceType: feature1.ResourceType, + ResourceId: feature1.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: freeTier.ResourceType, + ResourceId: freeTier.ResourceId, + }, + }) + if err != nil { + t.Fatal(err) + } + require.NotEmpty(t, warrantResponse.WarrantToken) + + warrantResponse, err = WriteWarrant(context.Background(), WriteWarrantOpts{ + Op: "delete", + ResourceType: freeTier.ResourceType, + ResourceId: freeTier.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: freeUser.ResourceType, + ResourceId: freeUser.ResourceId, + }, + }) + if err != nil { + t.Fatal(err) + } + require.NotEmpty(t, warrantResponse.WarrantToken) + + freeUserHasFeature, err = Check(context.Background(), CheckOpts{ + Checks: []WarrantCheck{ + { + ResourceType: feature1.ResourceType, + ResourceId: feature1.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: freeUser.ResourceType, + ResourceId: freeUser.ResourceId, + }, + }, + }, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.False(t, freeUserHasFeature.Authorized()) + + freeUserFeaturesList, err = Query(context.Background(), QueryOpts{ + Query: fmt.Sprintf("select feature where user:%s is member", freeUser.ResourceId), + Limit: 10, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Empty(t, freeUserFeaturesList.Data) + + featureUserTiersList, err = Query(context.Background(), QueryOpts{ + Query: fmt.Sprintf("select pricing-tier where user:%s is member", freeUser.ResourceId), + Limit: 10, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Empty(t, featureUserTiersList.Data) + + // Clean up + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: freeUser.ResourceType, + ResourceId: freeUser.ResourceId, + }) + if err != nil { + t.Fatal(err) + } + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: paidUser.ResourceType, + ResourceId: paidUser.ResourceId, + }) + if err != nil { + t.Fatal(err) + } + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: freeTier.ResourceType, + ResourceId: freeTier.ResourceId, + }) + if err != nil { + t.Fatal(err) + } + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: paidTier.ResourceType, + ResourceId: paidTier.ResourceId, + }) + if err != nil { + t.Fatal(err) + } + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: customFeature.ResourceType, + ResourceId: customFeature.ResourceId, + }) + if err != nil { + t.Fatal(err) + } + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: feature1.ResourceType, + ResourceId: feature1.ResourceId, + }) + if err != nil { + t.Fatal(err) + } + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: feature2.ResourceType, + ResourceId: feature2.ResourceId, + }) + if err != nil { + t.Fatal(err) + } +} + +func TestWarrants(t *testing.T) { + setup() + + user1, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "user", + ResourceId: "userA", + }) + if err != nil { + t.Fatal(err) + } + user2, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "user", + ResourceId: "userB", + }) + if err != nil { + t.Fatal(err) + } + newPermission, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "permission", + ResourceId: "perm1", + Meta: map[string]interface{}{ + "name": "Permission 1", + "description": "Permission 1", + }, + }) + if err != nil { + t.Fatal(err) + } + + userHasPermission, err := Check(context.Background(), CheckOpts{ + Checks: []WarrantCheck{ + { + ResourceType: newPermission.ResourceType, + ResourceId: newPermission.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: user1.ResourceType, + ResourceId: user1.ResourceId, + }, + }, + }, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.False(t, userHasPermission.Authorized()) + + warrantResponse, err := WriteWarrant(context.Background(), WriteWarrantOpts{ + ResourceType: newPermission.ResourceType, + ResourceId: newPermission.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: user1.ResourceType, + ResourceId: user1.ResourceId, + }, + }) + if err != nil { + t.Fatal(err) + } + require.NotEmpty(t, warrantResponse.WarrantToken) + + warrantResponse, err = WriteWarrant(context.Background(), WriteWarrantOpts{ + ResourceType: newPermission.ResourceType, + ResourceId: newPermission.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: user2.ResourceType, + ResourceId: user2.ResourceId, + }, + }) + if err != nil { + t.Fatal(err) + } + require.NotEmpty(t, warrantResponse.WarrantToken) + + warrants1, err := ListWarrants(context.Background(), ListWarrantsOpts{ + Limit: 1, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, warrants1.Data, 1) + require.Equal(t, newPermission.ResourceType, warrants1.Data[0].ResourceType) + require.Equal(t, newPermission.ResourceId, warrants1.Data[0].ResourceId) + require.Equal(t, "member", warrants1.Data[0].Relation) + require.Equal(t, user2.ResourceType, warrants1.Data[0].Subject.ResourceType) + require.Equal(t, user2.ResourceId, warrants1.Data[0].Subject.ResourceId) + + warrants2, err := ListWarrants(context.Background(), ListWarrantsOpts{ + Limit: 1, + After: warrants1.ListMetadata.After, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, warrants2.Data, 1) + require.Equal(t, newPermission.ResourceType, warrants2.Data[0].ResourceType) + require.Equal(t, newPermission.ResourceId, warrants2.Data[0].ResourceId) + require.Equal(t, "member", warrants2.Data[0].Relation) + require.Equal(t, user1.ResourceType, warrants2.Data[0].Subject.ResourceType) + require.Equal(t, user1.ResourceId, warrants2.Data[0].Subject.ResourceId) + + warrants3, err := ListWarrants(context.Background(), ListWarrantsOpts{ + SubjectType: "user", + SubjectId: user1.ResourceId, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, warrants3.Data, 1) + require.Equal(t, newPermission.ResourceType, warrants3.Data[0].ResourceType) + require.Equal(t, newPermission.ResourceId, warrants3.Data[0].ResourceId) + require.Equal(t, "member", warrants3.Data[0].Relation) + require.Equal(t, user1.ResourceType, warrants3.Data[0].Subject.ResourceType) + require.Equal(t, user1.ResourceId, warrants3.Data[0].Subject.ResourceId) + + userHasPermission, err = Check(context.Background(), CheckOpts{ + Checks: []WarrantCheck{ + { + ResourceType: newPermission.ResourceType, + ResourceId: newPermission.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: user1.ResourceType, + ResourceId: user1.ResourceId, + }, + }, + }, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.True(t, userHasPermission.Authorized()) + + queryResponse, err := Query(context.Background(), QueryOpts{ + Query: fmt.Sprintf("select permission where user:%s is member", user1.ResourceId), + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, queryResponse.Data, 1) + require.Equal(t, newPermission.ResourceType, queryResponse.Data[0].ResourceType) + require.Equal(t, newPermission.ResourceId, queryResponse.Data[0].ResourceId) + require.Equal(t, "member", queryResponse.Data[0].Relation) + + warrantResponse, err = WriteWarrant(context.Background(), WriteWarrantOpts{ + Op: "delete", + ResourceType: newPermission.ResourceType, + ResourceId: newPermission.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: user1.ResourceType, + ResourceId: user1.ResourceId, + }, + }) + if err != nil { + t.Fatal(err) + } + require.NotEmpty(t, warrantResponse.WarrantToken) + + userHasPermission, err = Check(context.Background(), CheckOpts{ + Checks: []WarrantCheck{ + { + ResourceType: newPermission.ResourceType, + ResourceId: newPermission.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: user1.ResourceType, + ResourceId: user1.ResourceId, + }, + }, + }, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.False(t, userHasPermission.Authorized()) + + // Clean up + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: user1.ResourceType, + ResourceId: user1.ResourceId, + }) + if err != nil { + t.Fatal(err) + } + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: user2.ResourceType, + ResourceId: user2.ResourceId, + }) + if err != nil { + t.Fatal(err) + } + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: newPermission.ResourceType, + ResourceId: newPermission.ResourceId, + }) + if err != nil { + t.Fatal(err) + } +} + +func TestBatchWarrants(t *testing.T) { + setup() + + newUser, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "user", + }) + if err != nil { + t.Fatal(err) + } + permission1, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "permission", + ResourceId: "perm1", + Meta: map[string]interface{}{ + "name": "Permission 1", + "description": "Permission 1", + }, + }) + if err != nil { + t.Fatal(err) + } + permission2, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "permission", + ResourceId: "perm2", + Meta: map[string]interface{}{ + "name": "Permission 2", + "description": "Permission 2", + }, + }) + if err != nil { + t.Fatal(err) + } + + userHasPermissions, err := CheckBatch(context.Background(), CheckBatchOpts{ + Checks: []WarrantCheck{ + { + ResourceType: permission1.ResourceType, + ResourceId: permission1.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: newUser.ResourceType, + ResourceId: newUser.ResourceId, + }, + }, + { + ResourceType: permission2.ResourceType, + ResourceId: permission2.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: newUser.ResourceType, + ResourceId: newUser.ResourceId, + }, + }, + }, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, userHasPermissions, 2) + require.False(t, userHasPermissions[0].Authorized()) + require.False(t, userHasPermissions[1].Authorized()) + + warrantResponse, err := BatchWriteWarrants(context.Background(), []WriteWarrantOpts{ + { + ResourceType: permission1.ResourceType, + ResourceId: permission1.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: newUser.ResourceType, + ResourceId: newUser.ResourceId, + }, + }, + { + Op: "create", + ResourceType: permission2.ResourceType, + ResourceId: permission2.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: newUser.ResourceType, + ResourceId: newUser.ResourceId, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + require.NotEmpty(t, warrantResponse.WarrantToken) + + userHasPermissions, err = CheckBatch(context.Background(), CheckBatchOpts{ + Checks: []WarrantCheck{ + { + ResourceType: permission1.ResourceType, + ResourceId: permission1.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: newUser.ResourceType, + ResourceId: newUser.ResourceId, + }, + }, + { + ResourceType: permission2.ResourceType, + ResourceId: permission2.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: newUser.ResourceType, + ResourceId: newUser.ResourceId, + }, + }, + }, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, userHasPermissions, 2) + require.True(t, userHasPermissions[0].Authorized()) + require.True(t, userHasPermissions[1].Authorized()) + + warrantResponse, err = BatchWriteWarrants(context.Background(), []WriteWarrantOpts{ + { + Op: "delete", + ResourceType: permission1.ResourceType, + ResourceId: permission1.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: newUser.ResourceType, + ResourceId: newUser.ResourceId, + }, + }, + { + Op: "delete", + ResourceType: permission2.ResourceType, + ResourceId: permission2.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: newUser.ResourceType, + ResourceId: newUser.ResourceId, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + require.NotEmpty(t, warrantResponse.WarrantToken) + + userHasPermissions, err = CheckBatch(context.Background(), CheckBatchOpts{ + Checks: []WarrantCheck{ + { + ResourceType: permission1.ResourceType, + ResourceId: permission1.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: newUser.ResourceType, + ResourceId: newUser.ResourceId, + }, + }, + { + ResourceType: permission2.ResourceType, + ResourceId: permission2.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: newUser.ResourceType, + ResourceId: newUser.ResourceId, + }, + }, + }, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, userHasPermissions, 2) + require.False(t, userHasPermissions[0].Authorized()) + require.False(t, userHasPermissions[1].Authorized()) + + // Clean up + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: newUser.ResourceType, + ResourceId: newUser.ResourceId, + }) + if err != nil { + t.Fatal(err) + } + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: permission1.ResourceType, + ResourceId: permission1.ResourceId, + }) + if err != nil { + t.Fatal(err) + } + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: permission2.ResourceType, + ResourceId: permission2.ResourceId, + }) + if err != nil { + t.Fatal(err) + } +} + +func TestWarrantsWithPolicy(t *testing.T) { + setup() + + warrantResponse, err := WriteWarrant(context.Background(), WriteWarrantOpts{ + ResourceType: "permission", + ResourceId: "test-permission", + Relation: "member", + Subject: Subject{ + ResourceType: "user", + ResourceId: "user-1", + }, + Policy: `geo == "us"`, + }) + if err != nil { + t.Fatal(err) + } + require.NotEmpty(t, warrantResponse.WarrantToken) + + checkResult, err := Check(context.Background(), CheckOpts{ + Checks: []WarrantCheck{ + { + ResourceType: "permission", + ResourceId: "test-permission", + Relation: "member", + Subject: Subject{ + ResourceType: "user", + ResourceId: "user-1", + }, + Context: map[string]interface{}{ + "geo": "us", + }, + }, + }, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.True(t, checkResult.Authorized()) + + checkResult, err = Check(context.Background(), CheckOpts{ + Checks: []WarrantCheck{ + { + ResourceType: "permission", + ResourceId: "test-permission", + Relation: "member", + Subject: Subject{ + ResourceType: "user", + ResourceId: "user-1", + }, + Context: map[string]interface{}{ + "geo": "eu", + }, + }, + }, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.False(t, checkResult.Authorized()) + + warrantResponse, err = WriteWarrant(context.Background(), WriteWarrantOpts{ + Op: "delete", + ResourceType: "permission", + ResourceId: "test-permission", + Relation: "member", + Subject: Subject{ + ResourceType: "user", + ResourceId: "user-1", + }, + Policy: `geo == "us"`, + }) + if err != nil { + t.Fatal(err) + } + require.NotEmpty(t, warrantResponse.WarrantToken) + + // Clean up + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: "permission", + ResourceId: "test-permission", + }) + if err != nil { + t.Fatal(err) + } + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: "user", + ResourceId: "user-1", + }) + if err != nil { + t.Fatal(err) + } +} + +func TestQueryWarrants(t *testing.T) { + setup() + + userA, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "user", + ResourceId: "userA", + }) + if err != nil { + t.Fatal(err) + } + userB, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "user", + ResourceId: "userB", + }) + if err != nil { + t.Fatal(err) + } + permission1, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "permission", + ResourceId: "perm1", + Meta: map[string]interface{}{ + "name": "Permission 1", + "description": "This is permission 1.", + }, + }) + if err != nil { + t.Fatal(err) + } + permission2, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "permission", + ResourceId: "perm2", + }) + if err != nil { + t.Fatal(err) + } + permission3, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "permission", + ResourceId: "perm3", + Meta: map[string]interface{}{ + "name": "Permission 3", + "description": "This is permission 3.", + }, + }) + if err != nil { + t.Fatal(err) + } + role1, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "role", + ResourceId: "role1", + Meta: map[string]interface{}{ + "name": "Role 1", + "description": "This is role 1.", + }, + }) + if err != nil { + t.Fatal(err) + } + role2, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "role", + ResourceId: "role2", + Meta: map[string]interface{}{ + "name": "Role 2", + }, + }) + if err != nil { + t.Fatal(err) + } + + warrantResponse, err := BatchWriteWarrants(context.Background(), []WriteWarrantOpts{ + { + ResourceType: permission1.ResourceType, + ResourceId: permission1.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: role1.ResourceType, + ResourceId: role1.ResourceId, + }, + }, + { + ResourceType: permission2.ResourceType, + ResourceId: permission2.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: role2.ResourceType, + ResourceId: role2.ResourceId, + }, + }, + { + ResourceType: permission3.ResourceType, + ResourceId: permission3.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: role2.ResourceType, + ResourceId: role2.ResourceId, + }, + }, + { + ResourceType: role2.ResourceType, + ResourceId: role2.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: role1.ResourceType, + ResourceId: role1.ResourceId, + }, + }, + { + ResourceType: permission1.ResourceType, + ResourceId: permission1.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: role2.ResourceType, + ResourceId: role2.ResourceId, + }, + Policy: "tenantId == 123", + }, + { + ResourceType: role1.ResourceType, + ResourceId: role1.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: userA.ResourceType, + ResourceId: userA.ResourceId, + }, + }, + { + ResourceType: role2.ResourceType, + ResourceId: role2.ResourceId, + Relation: "member", + Subject: Subject{ + ResourceType: userB.ResourceType, + ResourceId: userB.ResourceId, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + require.NotEmpty(t, warrantResponse.WarrantToken) + + queryResponse, err := Query(context.Background(), QueryOpts{ + Query: "select role where user:userA is member", + Limit: 1, + Order: Asc, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, queryResponse.Data, 1) + require.Equal(t, role1.ResourceType, queryResponse.Data[0].ResourceType) + require.Equal(t, role1.ResourceId, queryResponse.Data[0].ResourceId) + require.Equal(t, "member", queryResponse.Data[0].Relation) + require.Equal(t, role1.ResourceType, queryResponse.Data[0].Warrant.ResourceType) + require.Equal(t, role1.ResourceId, queryResponse.Data[0].Warrant.ResourceId) + require.Equal(t, "member", queryResponse.Data[0].Warrant.Relation) + require.Equal(t, userA.ResourceType, queryResponse.Data[0].Warrant.Subject.ResourceType) + require.Equal(t, userA.ResourceId, queryResponse.Data[0].Warrant.Subject.ResourceId) + require.Empty(t, queryResponse.Data[0].Warrant.Policy) + require.False(t, queryResponse.Data[0].IsImplicit) + + queryResponse, err = Query(context.Background(), QueryOpts{ + Query: "select role where user:userA is member", + Limit: 1, + Order: Asc, + After: queryResponse.ListMetadata.After, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, queryResponse.Data, 1) + require.Equal(t, role2.ResourceType, queryResponse.Data[0].ResourceType) + require.Equal(t, role2.ResourceId, queryResponse.Data[0].ResourceId) + require.Equal(t, "member", queryResponse.Data[0].Relation) + require.Equal(t, role2.ResourceType, queryResponse.Data[0].Warrant.ResourceType) + require.Equal(t, role2.ResourceId, queryResponse.Data[0].Warrant.ResourceId) + require.Equal(t, "member", queryResponse.Data[0].Warrant.Relation) + require.Equal(t, role1.ResourceType, queryResponse.Data[0].Warrant.Subject.ResourceType) + require.Equal(t, role1.ResourceId, queryResponse.Data[0].Warrant.Subject.ResourceId) + require.Empty(t, queryResponse.Data[0].Warrant.Policy) + require.True(t, queryResponse.Data[0].IsImplicit) + + queryResponse, err = Query(context.Background(), QueryOpts{ + Query: "select permission where user:userB is member", + Context: Context{ + "tenantId": 123, + }, + Order: Asc, + WarrantToken: "latest", + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, queryResponse.Data, 3) + require.Equal(t, permission1.ResourceType, queryResponse.Data[0].ResourceType) + require.Equal(t, permission1.ResourceId, queryResponse.Data[0].ResourceId) + require.Equal(t, "member", queryResponse.Data[0].Relation) + require.Equal(t, permission2.ResourceType, queryResponse.Data[1].ResourceType) + require.Equal(t, permission2.ResourceId, queryResponse.Data[1].ResourceId) + require.Equal(t, "member", queryResponse.Data[1].Relation) + require.Equal(t, permission3.ResourceType, queryResponse.Data[2].ResourceType) + require.Equal(t, permission3.ResourceId, queryResponse.Data[2].ResourceId) + require.Equal(t, "member", queryResponse.Data[2].Relation) + + // Clean up + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: role1.ResourceType, + ResourceId: role1.ResourceId, + }) + if err != nil { + t.Fatal(err) + } + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: role2.ResourceType, + ResourceId: role2.ResourceId, + }) + if err != nil { + t.Fatal(err) + } + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: permission1.ResourceType, + ResourceId: permission1.ResourceId, + }) + if err != nil { + t.Fatal(err) + } + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: permission2.ResourceType, + ResourceId: permission2.ResourceId, + }) + if err != nil { + t.Fatal(err) + } + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: permission3.ResourceType, + ResourceId: permission3.ResourceId, + }) + if err != nil { + t.Fatal(err) + } + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: userA.ResourceType, + ResourceId: userA.ResourceId, + }) + if err != nil { + t.Fatal(err) + } + err = DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: userB.ResourceType, + ResourceId: userB.ResourceId, + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/pkg/fga/client_test.go b/pkg/fga/client_test.go new file mode 100644 index 00000000..c3713aef --- /dev/null +++ b/pkg/fga/client_test.go @@ -0,0 +1,1317 @@ +package fga + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/workos/workos-go/v4/pkg/common" +) + +func TestGetResource(t *testing.T) { + tests := []struct { + scenario string + client *Client + options GetResourceOpts + expected Resource + err bool + }{ + { + scenario: "Request without API Key returns an error", + client: &Client{}, + err: true, + }, + { + scenario: "Request returns a Resource", + client: &Client{ + APIKey: "test", + }, + options: GetResourceOpts{ + ResourceType: "report", + ResourceId: "ljc_1029", + }, + expected: Resource{ + ResourceType: "report", + ResourceId: "ljc_1029", + }, + }, + } + + for _, test := range tests { + t.Run(test.scenario, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(getResourceTestHandler)) + defer server.Close() + + client := test.client + client.Endpoint = server.URL + client.HTTPClient = server.Client() + + resource, err := client.GetResource(context.Background(), test.options) + if test.err { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, test.expected, resource) + }) + } +} + +func getResourceTestHandler(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth != "Bearer test" { + http.Error(w, "bad auth", http.StatusUnauthorized) + return + } + + body, err := json.Marshal(Resource{ + ResourceType: "report", + ResourceId: "ljc_1029", + }) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + w.Write(body) +} + +func TestListResources(t *testing.T) { + tests := []struct { + scenario string + client *Client + options ListResourcesOpts + expected ListResourcesResponse + err bool + }{ + { + scenario: "Request without API Key returns an error", + client: &Client{}, + err: true, + }, + { + scenario: "Request returns Resources", + client: &Client{ + APIKey: "test", + }, + options: ListResourcesOpts{ + ResourceType: "report", + }, + + expected: ListResourcesResponse{ + Data: []Resource{ + { + ResourceType: "report", + ResourceId: "ljc_1029", + }, + { + ResourceType: "report", + ResourceId: "mso_0806", + }, + }, + ListMetadata: common.ListMetadata{ + Before: "", + After: "", + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.scenario, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(listResourcesTestHandler)) + defer server.Close() + + client := test.client + client.Endpoint = server.URL + client.HTTPClient = server.Client() + + resources, err := client.ListResources(context.Background(), test.options) + if test.err { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, test.expected, resources) + }) + } +} + +func listResourcesTestHandler(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth != "Bearer test" { + http.Error(w, "bad auth", http.StatusUnauthorized) + return + } + + if userAgent := r.Header.Get("User-Agent"); !strings.Contains(userAgent, "workos-go/") { + w.WriteHeader(http.StatusBadRequest) + return + } + + body, err := json.Marshal(struct { + ListResourcesResponse + }{ + ListResourcesResponse: ListResourcesResponse{ + Data: []Resource{ + { + ResourceType: "report", + ResourceId: "ljc_1029", + }, + { + ResourceType: "report", + ResourceId: "mso_0806", + }, + }, + ListMetadata: common.ListMetadata{ + Before: "", + After: "", + }, + }, + }) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + w.Write(body) +} + +func TestListResourceTypes(t *testing.T) { + tests := []struct { + scenario string + client *Client + options ListResourceTypesOpts + expected ListResourceTypesResponse + err bool + }{ + { + scenario: "Request without API Key returns an error", + client: &Client{}, + err: true, + }, + { + scenario: "Request returns ResourceTypes", + client: &Client{ + APIKey: "test", + }, + options: ListResourceTypesOpts{ + Order: "asc", + }, + + expected: ListResourceTypesResponse{ + Data: []ResourceType{ + { + Type: "report", + Relations: map[string]interface{}{ + "owner": map[string]interface{}{}, + "editor": map[string]interface{}{ + "inherit_if": "owner", + }, + "viewer": map[string]interface{}{ + "inherit_if": "editor", + }, + }, + }, + { + Type: "user", + Relations: map[string]interface{}{}, + }, + }, + ListMetadata: common.ListMetadata{ + Before: "", + After: "", + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.scenario, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(listResourceTypesTestHandler)) + defer server.Close() + + client := test.client + client.Endpoint = server.URL + client.HTTPClient = server.Client() + + resourceTypes, err := client.ListResourceTypes(context.Background(), test.options) + if test.err { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, test.expected, resourceTypes) + }) + } +} + +func listResourceTypesTestHandler(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth != "Bearer test" { + http.Error(w, "bad auth", http.StatusUnauthorized) + return + } + + if userAgent := r.Header.Get("User-Agent"); !strings.Contains(userAgent, "workos-go/") { + w.WriteHeader(http.StatusBadRequest) + return + } + + body, err := json.Marshal(struct { + ListResourceTypesResponse + }{ + ListResourceTypesResponse: ListResourceTypesResponse{ + Data: []ResourceType{ + { + Type: "report", + Relations: map[string]interface{}{ + "owner": map[string]interface{}{}, + "editor": map[string]interface{}{ + "inherit_if": "owner", + }, + "viewer": map[string]interface{}{ + "inherit_if": "editor", + }, + }, + }, + { + Type: "user", + Relations: map[string]interface{}{}, + }, + }, + ListMetadata: common.ListMetadata{ + Before: "", + After: "", + }, + }, + }) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + w.Write(body) +} + +func TestBatchUpdateResourceTypes(t *testing.T) { + tests := []struct { + scenario string + client *Client + options []UpdateResourceTypeOpts + expected []ResourceType + err bool + }{ + { + scenario: "Request without API Key returns an error", + client: &Client{}, + err: true, + }, + { + scenario: "Request returns ResourceTypes", + client: &Client{ + APIKey: "test", + }, + options: []UpdateResourceTypeOpts{ + { + Type: "report", + Relations: map[string]interface{}{ + "owner": map[string]interface{}{}, + "editor": map[string]interface{}{ + "inherit_if": "owner", + }, + "viewer": map[string]interface{}{ + "inherit_if": "editor", + }, + }, + }, + { + Type: "user", + Relations: map[string]interface{}{}, + }, + }, + + expected: []ResourceType{ + { + Type: "report", + Relations: map[string]interface{}{ + "owner": map[string]interface{}{}, + "editor": map[string]interface{}{ + "inherit_if": "owner", + }, + "viewer": map[string]interface{}{ + "inherit_if": "editor", + }, + }, + }, + { + Type: "user", + Relations: map[string]interface{}{}, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.scenario, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(batchUpdateResourceTypesTestHandler)) + defer server.Close() + + client := test.client + client.Endpoint = server.URL + client.HTTPClient = server.Client() + + resourceTypes, err := client.BatchUpdateResourceTypes(context.Background(), test.options) + if test.err { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, test.expected, resourceTypes) + }) + } +} + +func batchUpdateResourceTypesTestHandler(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth != "Bearer test" { + http.Error(w, "bad auth", http.StatusUnauthorized) + return + } + + if userAgent := r.Header.Get("User-Agent"); !strings.Contains(userAgent, "workos-go/") { + w.WriteHeader(http.StatusBadRequest) + return + } + + body, err := json.Marshal([]ResourceType{ + { + Type: "report", + Relations: map[string]interface{}{ + "owner": map[string]interface{}{}, + "editor": map[string]interface{}{ + "inherit_if": "owner", + }, + "viewer": map[string]interface{}{ + "inherit_if": "editor", + }, + }, + }, + { + Type: "user", + Relations: map[string]interface{}{}, + }, + }) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + w.Write(body) +} + +func TestCreateResource(t *testing.T) { + tests := []struct { + scenario string + client *Client + options CreateResourceOpts + expected Resource + err bool + }{ + { + scenario: "Request without API Key returns an error", + client: &Client{}, + err: true, + }, + { + scenario: "Request returns Resource", + client: &Client{ + APIKey: "test", + }, + options: CreateResourceOpts{ + ResourceType: "report", + ResourceId: "sso_1710", + }, + expected: Resource{ + ResourceType: "report", + ResourceId: "sso_1710", + }, + }, + { + scenario: "Request returns Resource with Metadata", + client: &Client{ + APIKey: "test", + }, + options: CreateResourceOpts{ + ResourceType: "report", + ResourceId: "sso_1710", + Meta: map[string]interface{}{ + "description": "Some report", + }, + }, + expected: Resource{ + ResourceType: "report", + ResourceId: "sso_1710", + Meta: map[string]interface{}{ + "description": "Some report", + }, + }, + }, + { + scenario: "Request with no ResourceId returns a Resource with generated report id", + client: &Client{ + APIKey: "test", + }, + options: CreateResourceOpts{ + ResourceType: "report", + }, + expected: Resource{ + ResourceType: "report", + ResourceId: "report_1029384756", + }, + }, + } + + for _, test := range tests { + t.Run(test.scenario, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(createResourceTestHandler)) + defer server.Close() + + client := test.client + client.Endpoint = server.URL + client.HTTPClient = server.Client() + + resource, err := client.CreateResource(context.Background(), test.options) + if test.err { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, test.expected, resource) + }) + } +} + +func createResourceTestHandler(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth != "Bearer test" { + http.Error(w, "bad auth", http.StatusUnauthorized) + return + } + + var opts CreateResourceOpts + json.NewDecoder(r.Body).Decode(&opts) + if userAgent := r.Header.Get("User-Agent"); !strings.Contains(userAgent, "workos-go/") { + w.WriteHeader(http.StatusBadRequest) + return + } + + resourceId := "sso_1710" + if opts.ResourceId == "" { + resourceId = "report_1029384756" + } + + body, err := json.Marshal( + Resource{ + ResourceType: "report", + ResourceId: resourceId, + Meta: opts.Meta, + }) + + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + w.Write(body) +} + +func TestUpdateResource(t *testing.T) { + tests := []struct { + scenario string + client *Client + options UpdateResourceOpts + expected Resource + err bool + }{ + { + scenario: "Request without API Key returns an error", + client: &Client{}, + err: true, + }, + { + scenario: "Request returns Resource with updated Meta", + client: &Client{ + APIKey: "test", + }, + options: UpdateResourceOpts{ + ResourceType: "report", + ResourceId: "lad_8812", + Meta: map[string]interface{}{ + "description": "Updated report", + }, + }, + expected: Resource{ + ResourceType: "report", + ResourceId: "lad_8812", + Meta: map[string]interface{}{ + "description": "Updated report", + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.scenario, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(updateResourceTestHandler)) + defer server.Close() + + client := test.client + client.Endpoint = server.URL + client.HTTPClient = server.Client() + + resource, err := client.UpdateResource(context.Background(), test.options) + if test.err { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, test.expected, resource) + }) + } +} + +func updateResourceTestHandler(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth != "Bearer test" { + http.Error(w, "bad auth", http.StatusUnauthorized) + return + } + + if userAgent := r.Header.Get("User-Agent"); !strings.Contains(userAgent, "workos-go/") { + w.WriteHeader(http.StatusBadRequest) + return + } + + body, err := json.Marshal( + Resource{ + ResourceType: "report", + ResourceId: "lad_8812", + Meta: map[string]interface{}{ + "description": "Updated report", + }, + }) + + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + w.Write(body) +} + +func TestDeleteResource(t *testing.T) { + tests := []struct { + scenario string + client *Client + options DeleteResourceOpts + expected error + err bool + }{ + { + scenario: "Request without API Key returns an error", + client: &Client{}, + err: true, + }, + { + scenario: "Request returns Resource", + client: &Client{ + APIKey: "test", + }, + options: DeleteResourceOpts{ + ResourceType: "user", + ResourceId: "user_01SXW182", + }, + expected: nil, + }, + { + scenario: "Request for non-existent Resource returns error", + client: &Client{ + APIKey: "test", + }, + err: true, + options: DeleteResourceOpts{ + ResourceType: "user", + ResourceId: "safgdfgs", + }, + }, + } + + for _, test := range tests { + t.Run(test.scenario, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(deleteResourceTestHandler)) + defer server.Close() + + client := test.client + client.Endpoint = server.URL + client.HTTPClient = server.Client() + + err := client.DeleteResource(context.Background(), test.options) + if test.err { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, test.expected, err) + }) + } +} + +func deleteResourceTestHandler(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth != "Bearer test" { + http.Error(w, "bad auth", http.StatusUnauthorized) + return + } + + var opts CreateResourceOpts + json.NewDecoder(r.Body).Decode(&opts) + + var body []byte + var err error + + if r.URL.Path == "/fga/v1/resources/user/user_01SXW182" { + body, err = nil, nil + } else { + http.Error(w, fmt.Sprintf("%s %s not found", opts.ResourceType, opts.ResourceId), http.StatusNotFound) + return + } + + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + w.Write(body) +} + +func TestListWarrants(t *testing.T) { + tests := []struct { + scenario string + client *Client + options ListWarrantsOpts + expected ListWarrantsResponse + err bool + }{ + { + scenario: "Request without API Key returns an error", + client: &Client{}, + err: true, + }, + { + scenario: "Request returns Warrants", + client: &Client{ + APIKey: "test", + }, + options: ListWarrantsOpts{ + ResourceType: "report", + }, + + expected: ListWarrantsResponse{ + Data: []Warrant{ + { + ResourceType: "report", + ResourceId: "ljc_1029", + Relation: "member", + Subject: Subject{ + ResourceType: "user", + ResourceId: "user_01SXW182", + }, + }, + { + ResourceType: "report", + ResourceId: "aut_7403", + Relation: "member", + Subject: Subject{ + ResourceType: "user", + ResourceId: "user_01SXW182", + }, + }, + }, + ListMetadata: common.ListMetadata{ + Before: "", + After: "", + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.scenario, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(listWarrantsTestHandler)) + defer server.Close() + + client := test.client + client.Endpoint = server.URL + client.HTTPClient = server.Client() + + resources, err := client.ListWarrants(context.Background(), test.options) + if test.err { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, test.expected, resources) + }) + } +} + +func listWarrantsTestHandler(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth != "Bearer test" { + http.Error(w, "bad auth", http.StatusUnauthorized) + return + } + + if userAgent := r.Header.Get("User-Agent"); !strings.Contains(userAgent, "workos-go/") { + w.WriteHeader(http.StatusBadRequest) + return + } + + body, err := json.Marshal(struct { + ListWarrantsResponse + }{ + ListWarrantsResponse: ListWarrantsResponse{ + Data: []Warrant{ + { + ResourceType: "report", + ResourceId: "ljc_1029", + Relation: "member", + Subject: Subject{ + ResourceType: "user", + ResourceId: "user_01SXW182", + }, + }, + { + ResourceType: "report", + ResourceId: "aut_7403", + Relation: "member", + Subject: Subject{ + ResourceType: "user", + ResourceId: "user_01SXW182", + }, + }, + }, + ListMetadata: common.ListMetadata{ + Before: "", + After: "", + }, + }, + }) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + w.Write(body) +} + +func TestWriteWarrant(t *testing.T) { + tests := []struct { + scenario string + client *Client + options WriteWarrantOpts + expected WriteWarrantResponse + err bool + }{ + { + scenario: "Request without API Key returns an error", + client: &Client{}, + err: true, + }, + { + scenario: "Request with no op returns WarrantToken", + client: &Client{ + APIKey: "test", + }, + options: WriteWarrantOpts{ + ResourceType: "report", + ResourceId: "sso_1710", + Relation: "member", + Subject: Subject{ + ResourceType: "user", + ResourceId: "user_01SXW182", + }, + }, + expected: WriteWarrantResponse{ + WarrantToken: "new_warrant_token", + }, + }, + { + scenario: "Request with create op returns WarrantToken", + client: &Client{ + APIKey: "test", + }, + options: WriteWarrantOpts{ + Op: WarrantOpCreate, + ResourceType: "report", + ResourceId: "sso_1710", + Relation: "member", + Subject: Subject{ + ResourceType: "user", + ResourceId: "user_01SXW182", + }, + }, + expected: WriteWarrantResponse{ + WarrantToken: "new_warrant_token", + }, + }, + { + scenario: "Request with delete op returns WarrantToken", + client: &Client{ + APIKey: "test", + }, + options: WriteWarrantOpts{ + Op: WarrantOpDelete, + ResourceType: "report", + ResourceId: "sso_1710", + Relation: "member", + Subject: Subject{ + ResourceType: "user", + ResourceId: "user_01SXW182", + }, + }, + expected: WriteWarrantResponse{ + WarrantToken: "new_warrant_token", + }, + }, + } + + for _, test := range tests { + t.Run(test.scenario, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(writeWarrantTestHandler)) + defer server.Close() + + client := test.client + client.Endpoint = server.URL + client.HTTPClient = server.Client() + + warrantResponse, err := client.WriteWarrant(context.Background(), test.options) + if test.err { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, test.expected, warrantResponse) + }) + } +} + +func TestBatchWriteWarrants(t *testing.T) { + tests := []struct { + scenario string + client *Client + options []WriteWarrantOpts + expected WriteWarrantResponse + err bool + }{ + { + scenario: "Request without API Key returns an error", + client: &Client{}, + err: true, + }, + { + scenario: "Request with multiple warrants returns WarrantToken", + client: &Client{ + APIKey: "test", + }, + options: []WriteWarrantOpts{ + { + Op: WarrantOpDelete, + ResourceType: "report", + ResourceId: "sso_1710", + Relation: "viewer", + Subject: Subject{ + ResourceType: "user", + ResourceId: "user_01SXW182", + }, + }, + { + Op: WarrantOpCreate, + ResourceType: "report", + ResourceId: "sso_1710", + Relation: "editor", + Subject: Subject{ + ResourceType: "user", + ResourceId: "user_01SXW182", + }, + }, + }, + expected: WriteWarrantResponse{ + WarrantToken: "new_warrant_token", + }, + }, + } + + for _, test := range tests { + t.Run(test.scenario, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(writeWarrantTestHandler)) + defer server.Close() + + client := test.client + client.Endpoint = server.URL + client.HTTPClient = server.Client() + + warrantResponse, err := client.BatchWriteWarrants(context.Background(), test.options) + if test.err { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, test.expected, warrantResponse) + }) + } +} + +func writeWarrantTestHandler(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth != "Bearer test" { + http.Error(w, "bad auth", http.StatusUnauthorized) + return + } + + if userAgent := r.Header.Get("User-Agent"); !strings.Contains(userAgent, "workos-go/") { + w.WriteHeader(http.StatusBadRequest) + return + } + + body, err := json.Marshal( + WriteWarrantResponse{ + WarrantToken: "new_warrant_token", + }) + + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + w.Write(body) +} + +func TestCheck(t *testing.T) { + tests := []struct { + scenario string + client *Client + options CheckOpts + expected CheckResponse + err bool + }{ + { + scenario: "Request without API Key returns an error", + client: &Client{}, + err: true, + }, + { + scenario: "Request returns true check result", + client: &Client{ + APIKey: "test", + }, + options: CheckOpts{ + Checks: []WarrantCheck{ + { + ResourceType: "report", + ResourceId: "ljc_1029", + Relation: "member", + Subject: Subject{ + ResourceType: "user", + ResourceId: "user_01SXW182", + }, + }, + }, + }, + expected: CheckResponse{ + Result: CheckResultAuthorized, + IsImplicit: false, + }, + }, + } + + for _, test := range tests { + t.Run(test.scenario, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(checkTestHandler)) + defer server.Close() + + client := test.client + client.Endpoint = server.URL + client.HTTPClient = server.Client() + + checkResult, err := client.Check(context.Background(), test.options) + if test.err { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, test.expected, checkResult) + }) + } +} + +func checkTestHandler(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth != "Bearer test" { + http.Error(w, "bad auth", http.StatusUnauthorized) + return + } + + if userAgent := r.Header.Get("User-Agent"); !strings.Contains(userAgent, "workos-go/") { + w.WriteHeader(http.StatusBadRequest) + return + } + + body, err := json.Marshal( + CheckResponse{ + Result: CheckResultAuthorized, + IsImplicit: false, + }) + + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + w.Write(body) +} + +func TestCheckBatch(t *testing.T) { + tests := []struct { + scenario string + client *Client + options CheckBatchOpts + expected []CheckResponse + err bool + }{ + { + scenario: "Request without API Key returns an error", + client: &Client{}, + err: true, + }, + { + scenario: "Request returns array of check results", + client: &Client{ + APIKey: "test", + }, + options: CheckBatchOpts{ + Checks: []WarrantCheck{ + { + ResourceType: "report", + ResourceId: "ljc_1029", + Relation: "member", + Subject: Subject{ + ResourceType: "user", + ResourceId: "user_01SXW182", + }, + }, + { + ResourceType: "report", + ResourceId: "spt_8521", + Relation: "member", + Subject: Subject{ + ResourceType: "user", + ResourceId: "user_01SXW182", + }, + }, + }, + }, + expected: []CheckResponse{ + { + Result: CheckResultAuthorized, + IsImplicit: false, + }, + { + Result: CheckResultNotAuthorized, + IsImplicit: false, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.scenario, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(checkBatchTestHandler)) + defer server.Close() + + client := test.client + client.Endpoint = server.URL + client.HTTPClient = server.Client() + + checkResults, err := client.CheckBatch(context.Background(), test.options) + if test.err { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, test.expected, checkResults) + }) + } +} + +func checkBatchTestHandler(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth != "Bearer test" { + http.Error(w, "bad auth", http.StatusUnauthorized) + return + } + + if userAgent := r.Header.Get("User-Agent"); !strings.Contains(userAgent, "workos-go/") { + w.WriteHeader(http.StatusBadRequest) + return + } + + body, err := json.Marshal( + []CheckResponse{ + { + Result: CheckResultAuthorized, + IsImplicit: false, + }, + { + Result: CheckResultNotAuthorized, + IsImplicit: false, + }, + }) + + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + w.Write(body) +} + +func TestQuery(t *testing.T) { + tests := []struct { + scenario string + client *Client + options QueryOpts + expected QueryResponse + err bool + }{ + { + scenario: "Request without API Key returns an error", + client: &Client{}, + err: true, + }, + { + scenario: "Request returns QueryResults", + client: &Client{ + APIKey: "test", + }, + options: QueryOpts{ + Query: "select role where user:user_01SXW182 is member", + }, + expected: QueryResponse{ + Data: []QueryResult{ + { + ResourceType: "role", + ResourceId: "role_01SXW182", + Relation: "member", + Warrant: Warrant{ + ResourceType: "role", + ResourceId: "role_01SXW182", + Relation: "member", + Subject: Subject{ + ResourceType: "user", + ResourceId: "user_01SXW182", + }, + }, + }, + }, + ListMetadata: common.ListMetadata{ + Before: "", + After: "", + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.scenario, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(queryTestHandler)) + defer server.Close() + + client := test.client + client.Endpoint = server.URL + client.HTTPClient = server.Client() + + queryResults, err := client.Query(context.Background(), test.options) + if test.err { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, test.expected, queryResults) + }) + } +} + +func queryTestHandler(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth != "Bearer test" { + http.Error(w, "bad auth", http.StatusUnauthorized) + return + } + + if userAgent := r.Header.Get("User-Agent"); !strings.Contains(userAgent, "workos-go/") { + w.WriteHeader(http.StatusBadRequest) + return + } + + body, err := json.Marshal(struct { + QueryResponse + }{ + QueryResponse: QueryResponse{ + Data: []QueryResult{ + { + ResourceType: "role", + ResourceId: "role_01SXW182", + Relation: "member", + Warrant: Warrant{ + ResourceType: "role", + ResourceId: "role_01SXW182", + Relation: "member", + Subject: Subject{ + ResourceType: "user", + ResourceId: "user_01SXW182", + }, + }, + }, + }, + ListMetadata: common.ListMetadata{ + Before: "", + After: "", + }, + }, + }) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + w.Write(body) +} diff --git a/pkg/fga/fga.go b/pkg/fga/fga.go new file mode 100644 index 00000000..70f226dd --- /dev/null +++ b/pkg/fga/fga.go @@ -0,0 +1,119 @@ +package fga + +import "context" + +// DefaultClient is the client used by SetAPIKey and FGA functions. +var ( + DefaultClient = &Client{ + Endpoint: "https://api.workos.com", + } +) + +// SetAPIKey sets the WorkOS API key for FGA requests. +func SetAPIKey(apiKey string) { + DefaultClient.APIKey = apiKey +} + +// GetResource gets a Resource. +func GetResource( + ctx context.Context, + opts GetResourceOpts, +) (Resource, error) { + return DefaultClient.GetResource(ctx, opts) +} + +// ListResources gets a list of Resources. +func ListResources( + ctx context.Context, + opts ListResourcesOpts, +) (ListResourcesResponse, error) { + return DefaultClient.ListResources(ctx, opts) +} + +// CreateResource creates a Resource. +func CreateResource( + ctx context.Context, + opts CreateResourceOpts, +) (Resource, error) { + return DefaultClient.CreateResource(ctx, opts) +} + +// UpdateResource updates a Resource. +func UpdateResource( + ctx context.Context, + opts UpdateResourceOpts, +) (Resource, error) { + return DefaultClient.UpdateResource(ctx, opts) +} + +// DeleteResource deletes a Resource. +func DeleteResource( + ctx context.Context, + opts DeleteResourceOpts, +) error { + return DefaultClient.DeleteResource(ctx, opts) +} + +// ListResourceTypes gets a list of ResourceTypes. +func ListResourceTypes( + ctx context.Context, + opts ListResourceTypesOpts, +) (ListResourceTypesResponse, error) { + return DefaultClient.ListResourceTypes(ctx, opts) +} + +// BatchUpdateResourceTypes sets the environment's object types to match the provided types. +func BatchUpdateResourceTypes( + ctx context.Context, + opts []UpdateResourceTypeOpts, +) ([]ResourceType, error) { + return DefaultClient.BatchUpdateResourceTypes(ctx, opts) +} + +// ListWarrants gets a list of Warrants. +func ListWarrants( + ctx context.Context, + opts ListWarrantsOpts, +) (ListWarrantsResponse, error) { + return DefaultClient.ListWarrants(ctx, opts) +} + +// WriteWarrant performs a write operation on a Warrant. +func WriteWarrant( + ctx context.Context, + opts WriteWarrantOpts, +) (WriteWarrantResponse, error) { + return DefaultClient.WriteWarrant(ctx, opts) +} + +// BatchWriteWarrants performs write operations on multiple Warrants in one request. +func BatchWriteWarrants( + ctx context.Context, + opts []WriteWarrantOpts, +) (WriteWarrantResponse, error) { + return DefaultClient.BatchWriteWarrants(ctx, opts) +} + +// Check performs access checks on multiple Warrants. +func Check( + ctx context.Context, + opts CheckOpts, +) (CheckResponse, error) { + return DefaultClient.Check(ctx, opts) +} + +// CheckBatch performs individual access checks on multiple Warrants in one request. +func CheckBatch( + ctx context.Context, + opts CheckBatchOpts, +) ([]CheckResponse, error) { + return DefaultClient.CheckBatch(ctx, opts) +} + +// Query performs a query for a set of resources. +func Query( + ctx context.Context, + opts QueryOpts, +) (QueryResponse, error) { + return DefaultClient.Query(ctx, opts) +} diff --git a/pkg/fga/fga_test.go b/pkg/fga/fga_test.go new file mode 100644 index 00000000..3ef0ef6b --- /dev/null +++ b/pkg/fga/fga_test.go @@ -0,0 +1,441 @@ +package fga + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + "github.com/workos/workos-go/v4/pkg/common" +) + +func TestFGAGetResource(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(getResourceTestHandler)) + defer server.Close() + + DefaultClient = &Client{ + HTTPClient: server.Client(), + Endpoint: server.URL, + } + SetAPIKey("test") + + expectedResponse := Resource{ + ResourceType: "report", + ResourceId: "ljc_1029", + } + resourceResponse, err := GetResource(context.Background(), GetResourceOpts{ + ResourceType: "report", + ResourceId: "ljc_1029", + }) + + require.NoError(t, err) + require.Equal(t, expectedResponse, resourceResponse) +} + +func TestFGAListResources(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(listResourcesTestHandler)) + defer server.Close() + + DefaultClient = &Client{ + HTTPClient: server.Client(), + Endpoint: server.URL, + } + SetAPIKey("test") + + expectedResponse := ListResourcesResponse{ + Data: []Resource{ + { + ResourceType: "report", + ResourceId: "ljc_1029", + }, + { + ResourceType: "report", + ResourceId: "mso_0806", + }, + }, + ListMetadata: common.ListMetadata{ + Before: "", + After: "", + }, + } + resourcesResponse, err := ListResources(context.Background(), ListResourcesOpts{ + ResourceType: "report", + }) + + require.NoError(t, err) + require.Equal(t, expectedResponse, resourcesResponse) +} + +func TestFGACreateResource(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(createResourceTestHandler)) + defer server.Close() + + DefaultClient = &Client{ + HTTPClient: server.Client(), + Endpoint: server.URL, + } + SetAPIKey("test") + + expectedResponse := Resource{ + ResourceType: "report", + ResourceId: "sso_1710", + } + createdResource, err := CreateResource(context.Background(), CreateResourceOpts{ + ResourceType: "report", + ResourceId: "sso_1710", + }) + + require.NoError(t, err) + require.Equal(t, expectedResponse, createdResource) +} + +func TestFGAUpdateResource(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(updateResourceTestHandler)) + defer server.Close() + + DefaultClient = &Client{ + HTTPClient: server.Client(), + Endpoint: server.URL, + } + SetAPIKey("test") + + expectedResponse := Resource{ + ResourceType: "report", + ResourceId: "lad_8812", + Meta: map[string]interface{}{ + "description": "Updated report", + }, + } + updatedResource, err := UpdateResource(context.Background(), UpdateResourceOpts{ + ResourceType: "report", + ResourceId: "lad_8812", + Meta: map[string]interface{}{ + "description": "Updated report", + }, + }) + + require.NoError(t, err) + require.Equal(t, expectedResponse, updatedResource) +} + +func TestFGADeleteResource(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(deleteResourceTestHandler)) + defer server.Close() + + DefaultClient = &Client{ + HTTPClient: server.Client(), + Endpoint: server.URL, + } + SetAPIKey("test") + + err := DeleteResource(context.Background(), DeleteResourceOpts{ + ResourceType: "user", + ResourceId: "user_01SXW182", + }) + + require.NoError(t, err) +} + +func TestFGAListResourceTypes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(listResourceTypesTestHandler)) + defer server.Close() + + DefaultClient = &Client{ + HTTPClient: server.Client(), + Endpoint: server.URL, + } + SetAPIKey("test") + + expectedResponse := ListResourceTypesResponse{ + Data: []ResourceType{ + { + Type: "report", + Relations: map[string]interface{}{ + "owner": map[string]interface{}{}, + "editor": map[string]interface{}{ + "inherit_if": "owner", + }, + "viewer": map[string]interface{}{ + "inherit_if": "editor", + }, + }, + }, + { + Type: "user", + Relations: map[string]interface{}{}, + }, + }, + ListMetadata: common.ListMetadata{ + Before: "", + After: "", + }, + } + resourceTypesResponse, err := ListResourceTypes(context.Background(), ListResourceTypesOpts{ + Order: "asc", + }) + + require.NoError(t, err) + require.Equal(t, expectedResponse, resourceTypesResponse) +} + +func TestFGABatchUpdateResourceTypes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(batchUpdateResourceTypesTestHandler)) + defer server.Close() + + DefaultClient = &Client{ + HTTPClient: server.Client(), + Endpoint: server.URL, + } + SetAPIKey("test") + + expectedResponse := []ResourceType{ + { + Type: "report", + Relations: map[string]interface{}{ + "owner": map[string]interface{}{}, + "editor": map[string]interface{}{ + "inherit_if": "owner", + }, + "viewer": map[string]interface{}{ + "inherit_if": "editor", + }, + }, + }, + { + Type: "user", + Relations: map[string]interface{}{}, + }, + } + resourceTypes, err := BatchUpdateResourceTypes(context.Background(), []UpdateResourceTypeOpts{ + { + Type: "report", + Relations: map[string]interface{}{ + "owner": map[string]interface{}{}, + "editor": map[string]interface{}{ + "inherit_if": "owner", + }, + "viewer": map[string]interface{}{ + "inherit_if": "editor", + }, + }, + }, + { + Type: "user", + Relations: map[string]interface{}{}, + }, + }) + + require.NoError(t, err) + require.Equal(t, expectedResponse, resourceTypes) +} + +func TestFGAListWarrants(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(listWarrantsTestHandler)) + defer server.Close() + + DefaultClient = &Client{ + HTTPClient: server.Client(), + Endpoint: server.URL, + } + SetAPIKey("test") + + expectedResponse := ListWarrantsResponse{ + Data: []Warrant{ + { + ResourceType: "report", + ResourceId: "ljc_1029", + Relation: "member", + Subject: Subject{ + ResourceType: "user", + ResourceId: "user_01SXW182", + }, + }, + { + ResourceType: "report", + ResourceId: "aut_7403", + Relation: "member", + Subject: Subject{ + ResourceType: "user", + ResourceId: "user_01SXW182", + }, + }, + }, + ListMetadata: common.ListMetadata{ + Before: "", + After: "", + }, + } + warrantsResponse, err := ListWarrants(context.Background(), ListWarrantsOpts{ + ResourceType: "report", + }) + + require.NoError(t, err) + require.Equal(t, expectedResponse, warrantsResponse) +} + +func TestFGAWriteWarrant(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(writeWarrantTestHandler)) + defer server.Close() + + DefaultClient = &Client{ + HTTPClient: server.Client(), + Endpoint: server.URL, + } + SetAPIKey("test") + + expectedResponse := WriteWarrantResponse{ + WarrantToken: "new_warrant_token", + } + warrantResponse, err := WriteWarrant(context.Background(), WriteWarrantOpts{ + Op: "create", + ResourceType: "report", + ResourceId: "sso_1710", + Relation: "member", + Subject: Subject{ + ResourceType: "user", + ResourceId: "user_01SXW182", + }, + }) + + require.NoError(t, err) + require.Equal(t, expectedResponse, warrantResponse) +} + +func TestFGABatchWriteWarrants(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(writeWarrantTestHandler)) + defer server.Close() + + DefaultClient = &Client{ + HTTPClient: server.Client(), + Endpoint: server.URL, + } + SetAPIKey("test") + + expectedResponse := WriteWarrantResponse{ + WarrantToken: "new_warrant_token", + } + warrantResponse, err := BatchWriteWarrants(context.Background(), []WriteWarrantOpts{ + { + Op: "delete", + ResourceType: "report", + ResourceId: "sso_1710", + Relation: "viewer", + Subject: Subject{ + ResourceType: "user", + ResourceId: "user_01SXW182", + }, + }, + { + Op: "create", + ResourceType: "report", + ResourceId: "sso_1710", + Relation: "editor", + Subject: Subject{ + ResourceType: "user", + ResourceId: "user_01SXW182", + }, + }, + }) + + require.NoError(t, err) + require.Equal(t, expectedResponse, warrantResponse) +} + +func TestFGACheck(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(checkTestHandler)) + defer server.Close() + + DefaultClient = &Client{ + HTTPClient: server.Client(), + Endpoint: server.URL, + } + SetAPIKey("test") + + checkResponse, err := Check(context.Background(), CheckOpts{ + Checks: []WarrantCheck{ + { + ResourceType: "report", + ResourceId: "ljc_1029", + Relation: "member", + Subject: Subject{ + ResourceType: "user", + ResourceId: "user_01SXW182", + }, + }, + }, + }) + + require.NoError(t, err) + require.True(t, checkResponse.Authorized()) +} + +func TestFGACheckBatch(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(checkBatchTestHandler)) + defer server.Close() + + DefaultClient = &Client{ + HTTPClient: server.Client(), + Endpoint: server.URL, + } + SetAPIKey("test") + + checkResponses, err := CheckBatch(context.Background(), CheckBatchOpts{ + Checks: []WarrantCheck{ + { + ResourceType: "report", + ResourceId: "ljc_1029", + Relation: "member", + Subject: Subject{ + ResourceType: "user", + ResourceId: "user_01SXW182", + }, + }, + }, + }) + + require.NoError(t, err) + require.Len(t, checkResponses, 2) + require.True(t, checkResponses[0].Authorized()) + require.False(t, checkResponses[1].Authorized()) +} + +func TestFGAQuery(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(queryTestHandler)) + defer server.Close() + + DefaultClient = &Client{ + HTTPClient: server.Client(), + Endpoint: server.URL, + } + SetAPIKey("test") + + expectedResponse := QueryResponse{ + Data: []QueryResult{ + { + ResourceType: "role", + ResourceId: "role_01SXW182", + Relation: "member", + Warrant: Warrant{ + ResourceType: "role", + ResourceId: "role_01SXW182", + Relation: "member", + Subject: Subject{ + ResourceType: "user", + ResourceId: "user_01SXW182", + }, + }, + }, + }, + ListMetadata: common.ListMetadata{ + Before: "", + After: "", + }, + } + queryResponse, err := Query(context.Background(), QueryOpts{ + Query: "select role where user:user_01SXW182 is member", + }) + + require.NoError(t, err) + require.Equal(t, expectedResponse, queryResponse) +} diff --git a/pkg/organizations/client.go b/pkg/organizations/client.go index f68e9d27..c4a5fbf8 100644 --- a/pkg/organizations/client.go +++ b/pkg/organizations/client.go @@ -163,7 +163,7 @@ type CreateOrganizationOpts struct { DomainData []OrganizationDomainData `json:"domain_data"` // Optional unique identifier to ensure idempotency - IdempotencyKey string `json:"idempotency_iey,omitempty"` + IdempotencyKey string `json:"idempotency_key,omitempty"` } // UpdateOrganizationOpts contains the options to update an Organization.