diff --git a/docs/docs/features-old.mdx b/docs/docs/features-old.mdx deleted file mode 100644 index 361b6fd9136..00000000000 --- a/docs/docs/features-old.mdx +++ /dev/null @@ -1,230 +0,0 @@ ---- -title: Feature Flags -description: Learn about feature flags and how to use them in your application. -sidebar_label: Feature Flags -slug: /app/features-old ---- - -# Feature Flags - -Feature Flags enable you to change your application's behavior from within the -GrowthBook UI. For example, turn on/off a sales banner or change the -title of your pricing page. - -You can set a global value for everyone, use advanced targeting to -assign values to users, and run experiments to see which value is -better. - -Feature flags aren't limited to front-end changes. Use them on your back-end -to gradually release a new ML model or enable new fields in an API response. - -## Environments - -GrowthBook comes with one environment by default (**production**), but you can add as many as you need on the [Environments page](https://app.growthbook.io/environments). - -Feature flags can be enabled and disabled on a per-environment basis. - -When a feature is disabled in an environment, it will always evaluate to `null` and ignore any other targeting or override rules. - -When configuring your SDK, there is no environment configuration in the SDK since you provide the key, which itself is linked to the environment. To use multiple environments in the same code base, you can use environment variables to set a dynamic key, e.g. `GROWTHBOOK_CLIENT_KEY='sdk-abc123'` and then reference that environment variable in your code base. Depending on the framework you're using, some environment variables are not exposed by default on the front-end unless provided an appropriate prefix, e.g. `NEXT_GROWTHBOOK_CLIENT_KEY='sdk-abc123'` in order to access environment variables in Next.js client code. - -:::note - -It's possible for a feature to be enabled for an environment and still be considered "off". This happens when its value is set to `false`, `null`, `0`, or an empty string. - -::: - -## Feature Keys - -Every feature is defined by a unique **key**. This is what the engineering team will reference in their code when they check the value of a feature. Feature keys cannot be changed after they are created, so take care when choosing one. - -Feature keys must be all lowercase and include only letters, numbers, hyphens, and underscores. - -Some examples of good feature keys: - -- `onboarding-checklist` - ON/OFF flag for a feature -- `checkout_button_color` - The color of the checkout button -- `results-per-page` - Number of search results to show per page - -## Feature Types - -Features can be a simple ON/OFF flag or a more complex data type (strings, numbers or JSON). - -ON/OFF flags can support any of the following use cases: - -- Decouple code deploys and releases -- Kill switch for production -- Gradual rollout of features -- Complex targeting and segmentation of features -- Validating feature releases with A/B tests - -More complex data types enable you to have more than 2 possible states. For example, if you have a checkout button that is currently blue, you could use an ON/OFF flag called `new-button-color` that sets it to red when ON, but this is pretty limiting since you can't easily try other colors in the future. Instead, if you use a flag `button-color` and make it a string data type, you can easily set the value to 'blue', 'red', 'green', or any other color without changing your code. - -## Targeting Attributes - -Feature values can be targeted to specific users or groups of users. In order for this to work, you must pass targeting attributes into the GrowthBook SDK and also list them in the GrowthBook App. - -This is an example of specifying the targeting attributes in the SDK: - -```ts -growthbook.setAttributes({ - id: user.id, - email: user.email, - country: user.country, - url: window.location.href, - userAgent: navigator.userAgent, - admin: user.isAdmin, - age: user.age, -}); -``` - -You can update the attributes in the GrowthBook App under **Settings** → **Attributes**: - -![List of targeting attributes](/images/edit-targeting-attributes.png) - -:::note - -The actual values of the targeting attributes (e.g. the user ids, emails, etc.) are never sent to GrowthBook. They are only stored in memory locally within the SDK. This architecture -eliminates huge potential security holes and keeps your user's PII safe and secure. - -::: - -Each attribute has 4 parts: - -- The **attribute name** itself. This is how the attribute will be referenced in the SDK. -- The **data type** of the attribute - - Boolean - true or false - - Number - Floats or integers - - String - freeform text - - Enum - When there are only a small list of pre-defined values it could take - - Secure String - Like a string, but the values will be hashed before passing to the SDK - - Array of Strings - useful for things like "tags" - - Array of Numbers - useful anytime you have multiple numeric values - - Array of Secure Strings - an array of secure strings useful for passing multiple values that you want to keep secure -- Whether or not it's an **identifier**. Identifiers are attributes which uniquely identify something - typically either a person, account, company, or device. -- The **projects** that the attribute is associated with. This is useful if some attributes are only relevant to certain projects. If no projects are selected, the attribute will be available for all projects. - -### Saved Groups - -Available via API or under _SDK Configuration → Saved Groups_, **Saved Groups** allows you to define a list of attribute values that can be referenced from feature targeting rules. Saved Group values are passed by reference, so updates will affect all references of this group used in your feature flag targeting rules. Each saved group specifies an attribute for which these are possible values. When targeting to attributes that have a Saved Group defined, you will have options _'Is in the Saved Group'_ and _'Is not in the Saved Group'_, and then you can select the Saved Group name. Saved groups are useful to avoid having to copy and paste lists of user attribute values between feature targeting conditions. - -For example, you could make a Saved Group called _“Enterprise Customer Ids”_ that is an array of secure strings, and use it to release all of your new enterprise features. If you later add or remove a customer from the group, it will automatically update all of the features. - -## Override Rules - -Every feature has a default value that is served to all users. The real power comes when you define **override rules** that let you run experiments and/or change the value based on targeting attributes. - -Override rules are defined separately for each environment (e.g. dev and production). This way you can, for example, test an experiment rule in dev first before deploying it to production. - -The first matching rule for a user will be applied, so order matters. If there are no matching rules, the default value will be used. - -### Conditions - -Rules can specify conditions to limit which users the rule applies to. These conditions are evaluated against the targeting attributes in the SDK. - -There is an easy-to-use UI for simple conditions: - -![Rule conditions UI](/images/rule-conditions.png) - -In advanced mode, you can specify conditions using the MongoDB query syntax. This enables you to have nested logic, advanced array operators and more. Here's an example: - -```js -// Either the user's name starts with "john" -// OR they are over 65 and have a kid who's a doctor -{ - "$or": [ - { - "name": { "$regex": "^john" } - }, - { - "age": { "$gt": 65 }, - "kids": { - "$elemMatch": { - "profession": "doctor" - } - } - } - ] -} -``` - -**Note**: We use the MongoDB query syntax because it is easy to read and write and is well documented. The conditions are never actually executed against a database. Instead, our SDKs include a light-weight interpreter for this syntax that runs entirely locally. - -### Forced Value - -The simplest type of override rule is a "Forced Value" rule. This forces everyone who matches the targeting condition to get a specific value. For example, you could have a feature default to OFF and use force rules to turn it ON for a specific list of countries. - -### Percentage Rollout - -Percentage Rollout rules let you gradually release a feature value to a random sample of your users. - -Rollouts are most useful when you want to make sure a new feature doesn't break your app or site. You start by releasing to maybe 10% of users. Then after a while if your metrics look ok, you increase to 30% and so on. - -For rollout rules, you choose a user attribute to use for the random sample. Users with the same attribute value will be treated the same (either included or not included in the rollout). For example, if you choose a "company" attribute, then multiple employees in the same company will get the same experience. - -### Experiments - -The last type of rule is an Experiment. This randomly splits users into buckets, assigns them different values, and tracks that assignment in your data warehouse or analytics tool. - -Experiments are most useful when you aren't sure which value to assign yet. - -Here's what an Experiment rule looks like in the GrowthBook UI: - -![Experiment rule](/images/experiment-rule.png) - -In the vast majority of cases, you want to split traffic based on either a logged-in user id or some sort of anonymous identifier like a device id or session cookie. As long as the user has the same value for this attribute, they will always get assigned the same variation. In rare cases, you may want to use an attribute such as company or account instead, which ensures all users in a company will see the same thing. - -If the total variation percents add up to less than 100%, the remaining users will skip the rule and fall through to the next matching one (or the default value) instead. - -You can analyze the result of an Experiment the same way you would any experiment in GrowthBook. - -### Scheduling Features - -![Feature Scheduling](/images/feature-scheduling.png) - -If you have a Pro or Enterprise plan, you can schedule features to turn on or off at a specific date and time. This is useful for things like turning features on or off for holidays or special promotions. - -Scheduling features is currently supported with all override rule types, including force rules, rollout rules, and experiment rules - and just like non-scheduled override rules, they will override the default value when all conditions are met. - -#### Namespaces - -If you have multiple experiments that may conflict with each other (e.g. background color and text color), you can use **namespaces** to make the conflicting experiments mutually exclusive. - -Users are randomly assigned a value from 0 to 1 for each namespace. Each experiment in a namespace has a range of values that it includes. Users are only part of an experiment if their value falls within the experiment's range. So as long as two experiment ranges do not overlap, users will only ever be in at most one of them. - -![Namespaces](/images/namespaces.png) - -Before you can use namespaces, you must configure them under SDK Connections → Namespaces. - -## Publishing Changes - -When you make changes to a feature's definition (default value or override rules), a new draft version of the feature is created automatically. This draft version is unpublished and is only visible within the GrowthBook UI, not to your users. - -You can continue adding changes to this draft and when you are ready, publish them all at once with an optional commit message. - -![Draft Modal](/images/draft-modal.png) - -## Revision History - -You can view a revision history for your feature and revert to a past version by clicking the blue revert icons. This will create a new draft based on the past version you select, so you can safely review it (or add additional changes) before deciding to publish. - -![Feature Revisions](/images/feature-revisions.png) - -## SDK Connections - -In order to use feature flags in your application, you need to create an **SDK Connection** in GrowthBook. - -At a high level, the SDK Connection generates a unique clientKey which grants read-only access to feature flags in a specific environment. The SDKs use this to fetch feature flag states and override rules from the GrowthBook API. - -On GrowthBook Cloud, we have a global CDN in front of the API (https://cdn.growthbook.io) to ensure low latency responses from anywhere in the world. The CDN has a 30-second TTL, so changes to a feature may take a little time to be reflected. - -### GrowthBook Proxy - -We also offer a pre-built proxy server you can deploy on your own infrastructure. -This can be put in front of either GrowthBook Cloud or a self-hosted GrowthBook instance. - -The GrowthBook Proxy offers the following benefits: - -- Fast - Requests served from an in-memory cache close to your app servers -- Scalable - A single Proxy server can handle over 10,000 reqs/second. Horizontally scale for more -- Responsive - Changes in GrowthBook are rolled out to users in under a second diff --git a/docs/docs/features/basics.mdx b/docs/docs/features/basics.mdx index fd804c56d8f..37ff069937d 100644 --- a/docs/docs/features/basics.mdx +++ b/docs/docs/features/basics.mdx @@ -15,13 +15,13 @@ If you make a mistake, you can always delete the feature and create a new one. Create Feature -Feature keys must be all lowercase and include only letters, numbers, hyphens, and underscores. +Feature keys must only include letters, numbers, hyphens, and underscores. Some examples of good feature keys: - `onboarding-checklist` - ON/OFF flag for a feature - `checkout_button_color` - The color of the checkout button -- `results-per-page` - Number of search results to show per page +- `resultsPerPage` - Number of search results to show per page ## Default Values diff --git a/packages/back-end/src/api/bulk-import/postBulkImportFacts.ts b/packages/back-end/src/api/bulk-import/postBulkImportFacts.ts index 5088e5693b3..82c6f2f8f07 100644 --- a/packages/back-end/src/api/bulk-import/postBulkImportFacts.ts +++ b/packages/back-end/src/api/bulk-import/postBulkImportFacts.ts @@ -20,232 +20,226 @@ import { getUpdateFactMetricPropsFromBody } from "../fact-metrics/updateFactMetr export const postBulkImportFacts = createApiRequestHandler( postBulkImportFactsValidator -)( - async (req): Promise => { - const numCreated = { - factTables: 0, - factTableFilters: 0, - factMetrics: 0, - }; - const numUpdated = { - factTables: 0, - factTableFilters: 0, - factMetrics: 0, - }; - - const factTableMap = await getFactTableMap(req.context); - - const allFactMetrics = await req.context.models.factMetrics.getAll(); - const factMetricMap = new Map( - allFactMetrics.map((m) => [m.id, m]) - ); - - const allDataSources = await getDataSourcesByOrganization(req.context); - const dataSourceMap = new Map( - allDataSources.map((s) => [s.id, s]) - ); - - const tagsToAdd = new Set(); - - const projects = await req.context.models.projects.getAll(); - const projectIds = new Set(projects.map((p) => p.id)); - function validateProjectIds(ids: string[]) { - for (const id of ids) { - if (!projectIds.has(id)) { - throw new Error(`Project ${id} not found`); - } +)(async (req): Promise => { + const numCreated = { + factTables: 0, + factTableFilters: 0, + factMetrics: 0, + }; + const numUpdated = { + factTables: 0, + factTableFilters: 0, + factMetrics: 0, + }; + + const factTableMap = await getFactTableMap(req.context); + + const allFactMetrics = await req.context.models.factMetrics.getAll(); + const factMetricMap = new Map( + allFactMetrics.map((m) => [m.id, m]) + ); + + const allDataSources = await getDataSourcesByOrganization(req.context); + const dataSourceMap = new Map( + allDataSources.map((s) => [s.id, s]) + ); + + const tagsToAdd = new Set(); + + const projects = await req.context.models.projects.getAll(); + const projectIds = new Set(projects.map((p) => p.id)); + function validateProjectIds(ids: string[]) { + for (const id of ids) { + if (!projectIds.has(id)) { + throw new Error(`Project ${id} not found`); } } + } - function validateUserIdTypes(datasourceId: string, ids: string[]) { - const datasource = dataSourceMap.get(datasourceId); - if (!datasource) return; - - for (const id of ids) { - if ( - !datasource.settings?.userIdTypes?.some((t) => t.userIdType === id) - ) { - throw new Error( - `User ID type ${id} not found in datasource ${datasourceId}` - ); - } + function validateUserIdTypes(datasourceId: string, ids: string[]) { + const datasource = dataSourceMap.get(datasourceId); + if (!datasource) return; + + for (const id of ids) { + if (!datasource.settings?.userIdTypes?.some((t) => t.userIdType === id)) { + throw new Error( + `User ID type ${id} not found in datasource ${datasourceId}` + ); } } + } - // Import fact tables - if (req.body.factTables) { - for (const { data, id } of req.body.factTables) { - data.tags?.forEach((t) => tagsToAdd.add(t)); - if (data.projects) validateProjectIds(data.projects); + // Import fact tables + if (req.body.factTables) { + for (const { data, id } of req.body.factTables) { + data.tags?.forEach((t) => tagsToAdd.add(t)); + if (data.projects) validateProjectIds(data.projects); - // This bulk endpoint is mostly used to sync from version control - // So default these resources to only be managed by API and not the UI - if (data.managedBy === undefined) { - data.managedBy = "api"; - } + // This bulk endpoint is mostly used to sync from version control + // So default these resources to only be managed by API and not the UI + if (data.managedBy === undefined) { + data.managedBy = "api"; + } - const existing = factTableMap.get(id); - // Update existing fact table - if (existing) { - if (!req.context.permissions.canUpdateFactTable(existing, data)) { - req.context.permissions.throwPermissionError(); - } - if (data.userIdTypes) { - validateUserIdTypes(existing.datasource, data.userIdTypes); - } - - // Cannot change data source - if (data.datasource && existing.datasource !== data.datasource) { - throw new Error( - "Cannot change data source for existing fact table" - ); - } - - await updateFactTable(req.context, existing, data); - await queueFactTableColumnsRefresh(existing); - factTableMap.set(existing.id, { - ...existing, - ...data, - }); - numUpdated.factTables++; + const existing = factTableMap.get(id); + // Update existing fact table + if (existing) { + if (!req.context.permissions.canUpdateFactTable(existing, data)) { + req.context.permissions.throwPermissionError(); } - // Create new fact table - else { - const factTable: CreateFactTableProps = { - columns: [], - eventName: "", - id: id, - description: "", - owner: "", - projects: [], - tags: [], - ...data, - }; - - if (!req.context.permissions.canCreateFactTable(factTable)) { - req.context.permissions.throwPermissionError(); - } - - if (!dataSourceMap.has(factTable.datasource)) { - throw new Error("Could not find datasource"); - } - - if (factTable.userIdTypes) { - validateUserIdTypes(factTable.datasource, factTable.userIdTypes); - } - - const newFactTable = await createFactTable(req.context, factTable); - await queueFactTableColumnsRefresh(newFactTable); - factTableMap.set(newFactTable.id, newFactTable); - numCreated.factTables++; + if (data.userIdTypes) { + validateUserIdTypes(existing.datasource, data.userIdTypes); } - } - } - // Import filters - if (req.body.factTableFilters) { - for (const { factTableId, data, id } of req.body.factTableFilters) { - const factTable = factTableMap.get(factTableId); - if (!factTable) { - throw new Error( - `Could not find fact table ${factTableId} for filter ${id}` - ); + + // Cannot change data source + if (data.datasource && existing.datasource !== data.datasource) { + throw new Error("Cannot change data source for existing fact table"); } - if (!req.context.permissions.canUpdateFactTable(factTable, {})) { + + await updateFactTable(req.context, existing, data); + await queueFactTableColumnsRefresh(existing); + factTableMap.set(existing.id, { + ...existing, + ...data, + }); + numUpdated.factTables++; + } + // Create new fact table + else { + const factTable: CreateFactTableProps = { + columns: [], + eventName: "", + id: id, + description: "", + owner: "", + projects: [], + tags: [], + ...data, + }; + + if (!req.context.permissions.canCreateFactTable(factTable)) { req.context.permissions.throwPermissionError(); } - // This bulk endpoint is mostly used to sync from version control - // So default these resources to only be managed by API and not the UI - if (factTable.managedBy === "api" && data.managedBy === undefined) { - data.managedBy = "api"; + if (!dataSourceMap.has(factTable.datasource)) { + throw new Error("Could not find datasource"); } - const existingFactFilter = factTable.filters.find((f) => f.id === id); - // Update existing filter - if (existingFactFilter) { - await updateFactFilter( - req.context, - factTable, - existingFactFilter.id, - data - ); - Object.assign(existingFactFilter, data); - numUpdated.factTableFilters++; - } - // Create new filter - else { - const newFilter = await createFactFilter(factTable, { - description: "", - ...data, - id: id, - }); - factTable.filters.push(newFilter); - numCreated.factTableFilters++; + if (factTable.userIdTypes) { + validateUserIdTypes(factTable.datasource, factTable.userIdTypes); } + + const newFactTable = await createFactTable(req.context, factTable); + await queueFactTableColumnsRefresh(newFactTable); + factTableMap.set(newFactTable.id, newFactTable); + numCreated.factTables++; } } - // Fact metrics - if (req.body.factMetrics) { - for (const { id: origId, data } of req.body.factMetrics) { - data.tags?.forEach((t) => tagsToAdd.add(t)); - if (data.projects) validateProjectIds(data.projects); - - const id = origId.match(/^fact__/) ? origId : `fact__${origId}`; - - // This bulk endpoint is mostly used to sync from version control - // So default these resources to only be managed by API and not the UI - if (data.managedBy === undefined) { - data.managedBy = "api"; - } - - const existing = factMetricMap.get(id); - // Update existing fact metric - if (existing) { - const changes = getUpdateFactMetricPropsFromBody(data, existing); + } + // Import filters + if (req.body.factTableFilters) { + for (const { factTableId, data, id } of req.body.factTableFilters) { + const factTable = factTableMap.get(factTableId); + if (!factTable) { + throw new Error( + `Could not find fact table ${factTableId} for filter ${id}` + ); + } + if (!req.context.permissions.canCreateAndUpdateFactFilter(factTable)) { + req.context.permissions.throwPermissionError(); + } - const newFactMetric = await req.context.models.factMetrics.update( - existing, - changes - ); - factMetricMap.set(existing.id, newFactMetric); + // This bulk endpoint is mostly used to sync from version control + // So default these resources to only be managed by API and not the UI + if (factTable.managedBy === "api" && data.managedBy === undefined) { + data.managedBy = "api"; + } - numUpdated.factMetrics++; - } - // Create new fact metric - else { - const lookupFactTable = async (id: string) => - factTableMap.get(id) || null; - - const createProps = await getCreateMetricPropsFromBody( - data, - req.organization, - lookupFactTable - ); - createProps.id = id; - - const newFactMetric = await req.context.models.factMetrics.create( - createProps - ); - factMetricMap.set(newFactMetric.id, newFactMetric); - - numCreated.factMetrics++; - } + const existingFactFilter = factTable.filters.find((f) => f.id === id); + // Update existing filter + if (existingFactFilter) { + await updateFactFilter( + req.context, + factTable, + existingFactFilter.id, + data + ); + Object.assign(existingFactFilter, data); + numUpdated.factTableFilters++; + } + // Create new filter + else { + const newFilter = await createFactFilter(factTable, { + description: "", + ...data, + id: id, + }); + factTable.filters.push(newFilter); + numCreated.factTableFilters++; } } + } + // Fact metrics + if (req.body.factMetrics) { + for (const { id: origId, data } of req.body.factMetrics) { + data.tags?.forEach((t) => tagsToAdd.add(t)); + if (data.projects) validateProjectIds(data.projects); + + const id = origId.match(/^fact__/) ? origId : `fact__${origId}`; + + // This bulk endpoint is mostly used to sync from version control + // So default these resources to only be managed by API and not the UI + if (data.managedBy === undefined) { + data.managedBy = "api"; + } + + const existing = factMetricMap.get(id); + // Update existing fact metric + if (existing) { + const changes = getUpdateFactMetricPropsFromBody(data, existing); + + const newFactMetric = await req.context.models.factMetrics.update( + existing, + changes + ); + factMetricMap.set(existing.id, newFactMetric); - // Update tags - if (tagsToAdd.size) { - await req.context.registerTags([...tagsToAdd]); + numUpdated.factMetrics++; + } + // Create new fact metric + else { + const lookupFactTable = async (id: string) => + factTableMap.get(id) || null; + + const createProps = await getCreateMetricPropsFromBody( + data, + req.organization, + lookupFactTable + ); + createProps.id = id; + + const newFactMetric = await req.context.models.factMetrics.create( + createProps + ); + factMetricMap.set(newFactMetric.id, newFactMetric); + + numCreated.factMetrics++; + } } + } - return { - success: true, - factTablesAdded: numCreated.factTables, - factTablesUpdated: numUpdated.factTables, - factTableFiltersAdded: numCreated.factTableFilters, - factTableFiltersUpdated: numUpdated.factTableFilters, - factMetricsAdded: numCreated.factMetrics, - factMetricsUpdated: numUpdated.factMetrics, - }; + // Update tags + if (tagsToAdd.size) { + await req.context.registerTags([...tagsToAdd]); } -); + + return { + success: true, + factTablesAdded: numCreated.factTables, + factTablesUpdated: numUpdated.factTables, + factTableFiltersAdded: numCreated.factTableFilters, + factTableFiltersUpdated: numUpdated.factTableFilters, + factMetricsAdded: numCreated.factMetrics, + factMetricsUpdated: numUpdated.factMetrics, + }; +}); diff --git a/packages/back-end/src/api/fact-tables/deleteFactTableFilter.ts b/packages/back-end/src/api/fact-tables/deleteFactTableFilter.ts index dfa53c466b6..9b1e9e18467 100644 --- a/packages/back-end/src/api/fact-tables/deleteFactTableFilter.ts +++ b/packages/back-end/src/api/fact-tables/deleteFactTableFilter.ts @@ -5,22 +5,18 @@ import { deleteFactTableFilterValidator } from "../../validators/openapi"; export const deleteFactTableFilter = createApiRequestHandler( deleteFactTableFilterValidator -)( - async (req): Promise => { - const factTable = await getFactTable(req.context, req.params.factTableId); - if (!factTable) { - throw new Error( - "Unable to delete - Could not find factTable with that id" - ); - } - - if (!req.context.permissions.canUpdateFactTable(factTable, {})) { - req.context.permissions.throwPermissionError(); - } - await deleteFactFilter(req.context, factTable, req.params.id); +)(async (req): Promise => { + const factTable = await getFactTable(req.context, req.params.factTableId); + if (!factTable) { + throw new Error("Unable to delete - Could not find factTable with that id"); + } - return { - deletedId: req.params.id, - }; + if (!req.context.permissions.canDeleteFactFilter(factTable)) { + req.context.permissions.throwPermissionError(); } -); + await deleteFactFilter(req.context, factTable, req.params.id); + + return { + deletedId: req.params.id, + }; +}); diff --git a/packages/back-end/src/api/fact-tables/postFactTableFilter.ts b/packages/back-end/src/api/fact-tables/postFactTableFilter.ts index 5dae80f2056..e41bcb653d8 100644 --- a/packages/back-end/src/api/fact-tables/postFactTableFilter.ts +++ b/packages/back-end/src/api/fact-tables/postFactTableFilter.ts @@ -9,36 +9,34 @@ import { postFactTableFilterValidator } from "../../validators/openapi"; export const postFactTableFilter = createApiRequestHandler( postFactTableFilterValidator -)( - async (req): Promise => { - const factTable = await getFactTable(req.context, req.params.factTableId); - if (!factTable) { - throw new Error("Could not find factTable with that id"); - } +)(async (req): Promise => { + const factTable = await getFactTable(req.context, req.params.factTableId); + if (!factTable) { + throw new Error("Could not find factTable with that id"); + } - if (!req.context.permissions.canUpdateFactTable(factTable, {})) { - req.context.permissions.throwPermissionError(); - } + if (!req.context.permissions.canCreateAndUpdateFactFilter(factTable)) { + req.context.permissions.throwPermissionError(); + } - if (req.body.managedBy === "api" && !factTable.managedBy) { - throw new Error( - "Cannot set filter to be managed by api unless Fact Table is also managed by api" - ); - } + if (req.body.managedBy === "api" && !factTable.managedBy) { + throw new Error( + "Cannot set filter to be managed by api unless Fact Table is also managed by api" + ); + } - const filter = await createFactFilter(factTable, { - description: "", - ...req.body, - }); + const filter = await createFactFilter(factTable, { + description: "", + ...req.body, + }); - return { - factTableFilter: toFactTableFilterApiInterface( - { - ...factTable, - filters: [...factTable.filters, filter], - }, - filter.id - ), - }; - } -); + return { + factTableFilter: toFactTableFilterApiInterface( + { + ...factTable, + filters: [...factTable.filters, filter], + }, + filter.id + ), + }; +}); diff --git a/packages/back-end/src/api/fact-tables/updateFactTableFilter.ts b/packages/back-end/src/api/fact-tables/updateFactTableFilter.ts index 5d15861dfb6..17778d08863 100644 --- a/packages/back-end/src/api/fact-tables/updateFactTableFilter.ts +++ b/packages/back-end/src/api/fact-tables/updateFactTableFilter.ts @@ -9,33 +9,32 @@ import { updateFactTableFilterValidator } from "../../validators/openapi"; export const updateFactTableFilter = createApiRequestHandler( updateFactTableFilterValidator -)( - async (req): Promise => { - const factTable = await getFactTable(req.context, req.params.factTableId); - if (!factTable) { - throw new Error("Could not find factTable with that id"); - } - if (!req.context.permissions.canUpdateFactTable(factTable, {})) { - req.context.permissions.throwPermissionError(); - } +)(async (req): Promise => { + const factTable = await getFactTable(req.context, req.params.factTableId); + if (!factTable) { + throw new Error("Could not find factTable with that id"); + } - if (req.body.managedBy === "api" && !factTable.managedBy) { - throw new Error( - "Cannot set filter to be managed by api unless Fact Table is also managed by api" - ); - } + if (!req.context.permissions.canCreateAndUpdateFactFilter(factTable)) { + req.context.permissions.throwPermissionError(); + } - await updateFactFilter(req.context, factTable, req.params.id, req.body); + if (req.body.managedBy === "api" && !factTable.managedBy) { + throw new Error( + "Cannot set filter to be managed by api unless Fact Table is also managed by api" + ); + } - const newFilters = [...factTable.filters]; - const filterIndex = newFilters.findIndex((f) => f.id === req.params.id); - newFilters[filterIndex] = { ...newFilters[filterIndex], ...req.body }; + await updateFactFilter(req.context, factTable, req.params.id, req.body); - return { - factTableFilter: toFactTableFilterApiInterface( - { ...factTable, filters: newFilters }, - req.params.id - ), - }; - } -); + const newFilters = [...factTable.filters]; + const filterIndex = newFilters.findIndex((f) => f.id === req.params.id); + newFilters[filterIndex] = { ...newFilters[filterIndex], ...req.body }; + + return { + factTableFilter: toFactTableFilterApiInterface( + { ...factTable, filters: newFilters }, + req.params.id + ), + }; +}); diff --git a/packages/back-end/src/api/features/postFeature.ts b/packages/back-end/src/api/features/postFeature.ts index 6318bd03f2c..71fcc4053a8 100644 --- a/packages/back-end/src/api/features/postFeature.ts +++ b/packages/back-end/src/api/features/postFeature.ts @@ -22,7 +22,8 @@ export type ApiFeatureEnvSettings = NonNullable< z.infer["environments"] >; -export type ApiFeatureEnvSettingsRules = ApiFeatureEnvSettings[keyof ApiFeatureEnvSettings]["rules"]; +export type ApiFeatureEnvSettingsRules = + ApiFeatureEnvSettings[keyof ApiFeatureEnvSettings]["rules"]; export const validateEnvKeys = ( orgEnvKeys: string[], @@ -90,7 +91,7 @@ export const postFeature = createApiRequestHandler(postFeatureValidator)( dateCreated: new Date(), dateUpdated: new Date(), organization: req.organization.id, - id: req.body.id.toLowerCase(), + id: req.body.id, archived: !!req.body.archived, version: 1, environmentSettings: {}, @@ -114,14 +115,19 @@ export const postFeature = createApiRequestHandler(postFeatureValidator)( // ensure default value matches value type feature.defaultValue = validateFeatureValue(feature, feature.defaultValue); - req.checkPermissions( - "publishFeatures", - feature.project, - getEnabledEnvironments( + if ( + !req.context.permissions.canPublishFeature( feature, - orgEnvs.map((e) => e.id) + Array.from( + getEnabledEnvironments( + feature, + orgEnvs.map((e) => e.id) + ) + ) ) - ); + ) { + req.context.permissions.throwPermissionError(); + } addIdsToRules(feature.environmentSettings, feature.id); diff --git a/packages/back-end/src/api/features/toggleFeature.ts b/packages/back-end/src/api/features/toggleFeature.ts index 9bdfdcc710f..65969c98c9c 100644 --- a/packages/back-end/src/api/features/toggleFeature.ts +++ b/packages/back-end/src/api/features/toggleFeature.ts @@ -19,14 +19,15 @@ export const toggleFeature = createApiRequestHandler(toggleFeatureValidator)( const environmentIds = getEnvironmentIdsFromOrg(req.organization); - if (!req.context.permissions.canUpdateFeature(feature, {})) { + if ( + !req.context.permissions.canUpdateFeature(feature, {}) || + !req.context.permissions.canPublishFeature( + feature, + Object.keys(req.body.environments) + ) + ) { req.context.permissions.throwPermissionError(); } - req.checkPermissions( - "publishFeatures", - feature.project, - Object.keys(req.body.environments) - ); const toggles: Record = {}; Object.keys(req.body.environments).forEach((env) => { diff --git a/packages/back-end/src/api/features/updateFeature.ts b/packages/back-end/src/api/features/updateFeature.ts index ce30f651250..976d23cdcdf 100644 --- a/packages/back-end/src/api/features/updateFeature.ts +++ b/packages/back-end/src/api/features/updateFeature.ts @@ -39,16 +39,18 @@ export const updateFeature = createApiRequestHandler(updateFeatureValidator)( } if (project != null) { - req.checkPermissions( - "publishFeatures", - feature.project, - getEnabledEnvironments(feature, orgEnvs) - ); - req.checkPermissions( - "publishFeatures", - project, - getEnabledEnvironments(feature, orgEnvs) - ); + if ( + !req.context.permissions.canPublishFeature( + feature, + Array.from(getEnabledEnvironments(feature, orgEnvs)) + ) || + !req.context.permissions.canPublishFeature( + { project }, + Array.from(getEnabledEnvironments(feature, orgEnvs)) + ) + ) { + req.context.permissions.throwPermissionError(); + } } // ensure environment keys are valid @@ -92,17 +94,22 @@ export const updateFeature = createApiRequestHandler(updateFeatureValidator)( updates.project != null || updates.archived != null ) { - req.checkPermissions( - "publishFeatures", - updates.project, - getEnabledEnvironments( - { - ...feature, - ...updates, - }, - orgEnvs + if ( + !req.context.permissions.canPublishFeature( + updates, + Array.from( + getEnabledEnvironments( + { + ...feature, + ...updates, + }, + orgEnvs + ) + ) ) - ); + ) { + req.context.permissions.throwPermissionError(); + } addIdsToRules(updates.environmentSettings, feature.id); } diff --git a/packages/back-end/src/controllers/experimentLaunchChecklist.ts b/packages/back-end/src/controllers/experimentLaunchChecklist.ts index 382eaf25f5b..afed06f3bdd 100644 --- a/packages/back-end/src/controllers/experimentLaunchChecklist.ts +++ b/packages/back-end/src/controllers/experimentLaunchChecklist.ts @@ -137,7 +137,9 @@ export async function putManualLaunchChecklist( const envs = experiment ? getAffectedEnvsForExperiment({ experiment }) : []; - req.checkPermissions("runExperiments", experiment?.project || "", envs); + if (!context.permissions.canRunExperiment(experiment, envs)) { + context.permissions.throwPermissionError(); + } await updateExperiment({ context, diff --git a/packages/back-end/src/controllers/experiments.ts b/packages/back-end/src/controllers/experiments.ts index b8660c1eaa5..92542386978 100644 --- a/packages/back-end/src/controllers/experiments.ts +++ b/packages/back-end/src/controllers/experiments.ts @@ -829,17 +829,19 @@ export async function postExperiment( } // Only some fields affect production SDK payloads - const needsRunExperimentsPermission = ([ - "phases", - "variations", - "project", - "name", - "trackingKey", - "archived", - "status", - "releasedVariationId", - "excludeFromPayload", - ] as (keyof ExperimentInterfaceStringDates)[]).some((key) => key in changes); + const needsRunExperimentsPermission = ( + [ + "phases", + "variations", + "project", + "name", + "trackingKey", + "archived", + "status", + "releasedVariationId", + "excludeFromPayload", + ] as (keyof ExperimentInterfaceStringDates)[] + ).some((key) => key in changes); if (needsRunExperimentsPermission) { const envs = getAffectedEnvsForExperiment({ experiment, @@ -849,7 +851,12 @@ export async function postExperiment( if ("project" in changes) { projects.push(changes.project || undefined); } - req.checkPermissions("runExperiments", projects, envs); + // check user's permission on existing experiment project and the updated project, if changed + projects.forEach((project) => { + if (!context.permissions.canRunExperiment({ project }, envs)) { + context.permissions.throwPermissionError(); + } + }); } } @@ -955,8 +962,12 @@ export async function postExperimentArchive( const envs = getAffectedEnvsForExperiment({ experiment, }); - envs.length > 0 && - req.checkPermissions("runExperiments", experiment.project, envs); + if ( + envs.length > 0 && + !context.permissions.canRunExperiment(experiment, envs) + ) { + context.permissions.throwPermissionError(); + } changes.archived = true; @@ -1080,8 +1091,13 @@ export async function postExperimentStatus( const envs = getAffectedEnvsForExperiment({ experiment, }); - envs.length > 0 && - req.checkPermissions("runExperiments", experiment.project, envs); + + if ( + envs.length > 0 && + !context.permissions.canRunExperiment(experiment, envs) + ) { + context.permissions.throwPermissionError(); + } // If status changed from running to stopped, update the latest phase const phases = [...experiment.phases]; @@ -1190,8 +1206,13 @@ export async function postExperimentStop( const envs = getAffectedEnvsForExperiment({ experiment, }); - envs.length > 0 && - req.checkPermissions("runExperiments", experiment.project, envs); + + if ( + envs.length > 0 && + !context.permissions.canRunExperiment(experiment, envs) + ) { + context.permissions.throwPermissionError(); + } const phases = [...experiment.phases]; // Already has phases @@ -1280,8 +1301,13 @@ export async function deleteExperimentPhase( const envs = getAffectedEnvsForExperiment({ experiment, }); - envs.length > 0 && - req.checkPermissions("runExperiments", experiment.project, envs); + + if ( + envs.length > 0 && + !context.permissions.canRunExperiment(experiment, envs) + ) { + context.permissions.throwPermissionError(); + } if (phaseIndex < 0 || phaseIndex >= experiment.phases?.length) { throw new Error("Invalid phase id"); @@ -1349,8 +1375,13 @@ export async function putExperimentPhase( const envs = getAffectedEnvsForExperiment({ experiment, }); - envs.length > 0 && - req.checkPermissions("runExperiments", experiment.project, envs); + + if ( + envs.length > 0 && + !context.permissions.canRunExperiment(experiment, envs) + ) { + context.permissions.throwPermissionError(); + } phase.dateStarted = phase.dateStarted ? getValidDate(phase.dateStarted + ":00Z") @@ -1431,8 +1462,13 @@ export async function postExperimentTargeting( const envs = getAffectedEnvsForExperiment({ experiment, }); - envs.length > 0 && - req.checkPermissions("runExperiments", experiment.project, envs); + + if ( + envs.length > 0 && + !context.permissions.canRunExperiment(experiment, envs) + ) { + context.permissions.throwPermissionError(); + } const phases = [...experiment.phases]; @@ -1547,8 +1583,13 @@ export async function postExperimentPhase( const envs = getAffectedEnvsForExperiment({ experiment, }); - envs.length > 0 && - req.checkPermissions("runExperiments", experiment.project, envs); + + if ( + envs.length > 0 && + !context.permissions.canRunExperiment(experiment, envs) + ) { + context.permissions.throwPermissionError(); + } const date = dateStarted ? getValidDate(dateStarted + ":00Z") : new Date(); @@ -1662,8 +1703,13 @@ export async function deleteExperiment( const envs = getAffectedEnvsForExperiment({ experiment, }); - envs.length > 0 && - req.checkPermissions("runExperiments", experiment.project, envs); + + if ( + envs.length > 0 && + !context.permissions.canRunExperiment(experiment, envs) + ) { + context.permissions.throwPermissionError(); + } await Promise.all([ // note: we might want to change this to change the status to @@ -1747,7 +1793,8 @@ async function createExperimentSnapshot({ } const { org } = context; - const orgSettings: OrganizationSettings = org.settings as OrganizationSettings; + const orgSettings: OrganizationSettings = + org.settings as OrganizationSettings; const { settings } = getScopedSettings({ organization: org, project: project ?? undefined, @@ -1773,20 +1820,18 @@ async function createExperimentSnapshot({ ) ).filter(Boolean) as MetricInterface[]; - const { - metricRegressionAdjustmentStatuses, - regressionAdjustmentEnabled, - } = getAllMetricRegressionAdjustmentStatuses({ - allExperimentMetrics, - denominatorMetrics, - orgSettings, - statsEngine, - experimentRegressionAdjustmentEnabled: - experiment.regressionAdjustmentEnabled, - experimentMetricOverrides: experiment.metricOverrides, - datasourceType: datasource?.type, - hasRegressionAdjustmentFeature: true, - }); + const { metricRegressionAdjustmentStatuses, regressionAdjustmentEnabled } = + getAllMetricRegressionAdjustmentStatuses({ + allExperimentMetrics, + denominatorMetrics, + orgSettings, + statsEngine, + experimentRegressionAdjustmentEnabled: + experiment.regressionAdjustmentEnabled, + experimentMetricOverrides: experiment.metricOverrides, + datasourceType: datasource?.type, + hasRegressionAdjustmentFeature: true, + }); const analysisSettings = getDefaultExperimentAnalysisSettings( statsEngine, @@ -2363,8 +2408,13 @@ export async function postVisualChangeset( const envs = getAffectedEnvsForExperiment({ experiment, }); - envs.length > 0 && - req.checkPermissions("runExperiments", experiment.project, envs); + + if ( + envs.length > 0 && + !context.permissions.canRunExperiment(experiment, envs) + ) { + context.permissions.throwPermissionError(); + } const visualChangeset = await createVisualChangeset({ experiment, @@ -2406,7 +2456,9 @@ export async function putVisualChangeset( }; const envs = experiment ? getAffectedEnvsForExperiment({ experiment }) : []; - req.checkPermissions("runExperiments", experiment?.project || "", envs); + if (!context.permissions.canRunExperiment(experiment, envs)) { + context.permissions.throwPermissionError(); + } const ret = await updateVisualChangeset({ visualChangeset, @@ -2443,7 +2495,9 @@ export async function deleteVisualChangeset( ); const envs = experiment ? getAffectedEnvsForExperiment({ experiment }) : []; - req.checkPermissions("runExperiments", experiment?.project || "", envs); + if (!context.permissions.canRunExperiment(experiment || {}, envs)) { + context.permissions.throwPermissionError(); + } await deleteVisualChangesetById({ visualChangeset, diff --git a/packages/back-end/src/controllers/features.ts b/packages/back-end/src/controllers/features.ts index 499b956c8c5..e5420fcc1ec 100644 --- a/packages/back-end/src/controllers/features.ts +++ b/packages/back-end/src/controllers/features.ts @@ -405,6 +405,15 @@ export async function postFeatures( throw new Error("Must specify feature key"); } + if (org.settings?.featureRegexValidator) { + const regex = new RegExp(org.settings.featureRegexValidator); + if (!regex.test(id)) { + throw new Error( + `Feature key must match the regex validator. '${org.settings.featureRegexValidator}' Example: '${org.settings.featureKeyExample}'` + ); + } + } + if (!environmentSettings) { throw new Error("Feature missing initial environment toggle settings"); } @@ -432,7 +441,7 @@ export async function postFeatures( dateCreated: new Date(), dateUpdated: new Date(), organization: org.id, - id: id.toLowerCase(), + id, archived: false, version: 1, hasDrafts: false, @@ -454,12 +463,14 @@ export async function postFeatures( ) ); - // Require publish permission for any enabled environments - req.checkPermissions( - "publishFeatures", - feature.project, - getEnabledEnvironments(feature, environmentIds) - ); + if ( + !context.permissions.canPublishFeature( + feature, + Array.from(getEnabledEnvironments(feature, environmentIds)) + ) + ) { + context.permissions.throwPermissionError(); + } addIdsToRules(feature.environmentSettings, feature.id); @@ -756,17 +767,22 @@ export async function postFeaturePublish( // If changing the default value, it affects all enabled environments if (mergeResult.result.defaultValue !== undefined) { - req.checkPermissions( - "publishFeatures", - feature.project, - getEnabledEnvironments(feature, environmentIds) - ); + if ( + !context.permissions.canPublishFeature( + feature, + Array.from(getEnabledEnvironments(feature, environmentIds)) + ) + ) { + context.permissions.throwPermissionError(); + } } // Otherwise, only the environments with rule changes are affected else { const changedEnvs = Object.keys(mergeResult.result.rules || {}); if (changedEnvs.length > 0) { - req.checkPermissions("publishFeatures", feature.project, changedEnvs); + if (!context.permissions.canPublishFeature(feature, changedEnvs)) { + context.permissions.throwPermissionError(); + } } } @@ -833,12 +849,14 @@ export async function postFeatureRevert( const changes: MergeResultChanges = {}; if (revision.defaultValue !== feature.defaultValue) { - // If changing the default value, it affects all enabled environments - req.checkPermissions( - "publishFeatures", - feature.project, - getEnabledEnvironments(feature, environmentIds) - ); + if ( + !context.permissions.canPublishFeature( + feature, + Array.from(getEnabledEnvironments(feature, environmentIds)) + ) + ) { + context.permissions.throwPermissionError(); + } changes.defaultValue = revision.defaultValue; } @@ -857,7 +875,9 @@ export async function postFeatureRevert( } }); if (changedEnvs.length > 0) { - req.checkPermissions("publishFeatures", feature.project, changedEnvs); + if (!context.permissions.canPublishFeature(feature, changedEnvs)) { + context.permissions.throwPermissionError(); + } } const updatedFeature = await applyRevisionChanges( @@ -1079,11 +1099,14 @@ export async function postFeatureSync( context.permissions.throwPermissionError(); } - req.checkPermissions( - "publishFeatures", - feature.project, - getEnabledEnvironments(feature, environments) - ); + if ( + !context.permissions.canPublishFeature( + feature, + Array.from(getEnabledEnvironments(feature, environments)) + ) + ) { + context.permissions.throwPermissionError(); + } if (data.valueType && data.valueType !== feature.valueType) { throw new Error( @@ -1199,11 +1222,14 @@ export async function postFeatureExperimentRefRule( context.permissions.throwPermissionError(); } - req.checkPermissions( - "publishFeatures", - feature.project, - getEnabledEnvironments(feature, environments) - ); + if ( + !context.permissions.canPublishFeature( + feature, + Array.from(getEnabledEnvironments(feature, environments)) + ) + ) { + context.permissions.throwPermissionError(); + } const experiment = await getExperimentById(context, rule.experimentId); if (!experiment) { @@ -1528,10 +1554,12 @@ export async function postFeatureToggle( throw new Error("Invalid environment"); } - if (!context.permissions.canUpdateFeature(feature, {})) { + if ( + !context.permissions.canUpdateFeature(feature, {}) || + !context.permissions.canPublishFeature(feature, [environment]) + ) { context.permissions.throwPermissionError(); } - req.checkPermissions("publishFeatures", feature.project, [environment]); const currentState = feature.environmentSettings?.[environment]?.enabled || false; @@ -1733,16 +1761,18 @@ export async function putFeature( // Changing the project can affect whether or not it's published if using project-scoped api keys if ("project" in updates) { // Make sure they have access in both the old and new environments - req.checkPermissions( - "publishFeatures", - feature.project, - getEnabledEnvironments(feature, environments) - ); - req.checkPermissions( - "publishFeatures", - updates.project, - getEnabledEnvironments(feature, environments) - ); + if ( + !context.permissions.canPublishFeature( + feature, + Array.from(getEnabledEnvironments(feature, environments)) + ) || + !context.permissions.canPublishFeature( + updates, + Array.from(getEnabledEnvironments(feature, environments)) + ) + ) { + context.permissions.throwPermissionError(); + } } const allowedKeys: (keyof FeatureInterface)[] = [ @@ -1796,16 +1826,14 @@ export async function deleteFeatureById( if ( !context.permissions.canDeleteFeature(feature) || - !context.permissions.canManageFeatureDrafts(feature) + !context.permissions.canManageFeatureDrafts(feature) || + !context.permissions.canPublishFeature( + feature, + Array.from(getEnabledEnvironments(feature, environmentsIds)) + ) ) { context.permissions.throwPermissionError(); } - - req.checkPermissions( - "publishFeatures", - feature.project, - getEnabledEnvironments(feature, environmentsIds) - ); await deleteFeature(context, feature); await req.audit({ event: "feature.delete", @@ -1839,11 +1867,8 @@ export async function postFeatureEvaluate( const { id, version } = req.params; const context = getContextFromReq(req); const { org } = context; - const { - attributes, - scrubPrerequisites, - skipRulesWithPrerequisites, - } = req.body; + const { attributes, scrubPrerequisites, skipRulesWithPrerequisites } = + req.body; const feature = await getFeature(context, id); if (!feature) { @@ -1892,14 +1917,16 @@ export async function postFeatureArchive( const environments = filterEnvironmentsByFeature(allEnvironments, feature); const environmentsIds = environments.map((e) => e.id); - if (!context.permissions.canUpdateFeature(feature, {})) { + if ( + !context.permissions.canUpdateFeature(feature, {}) || + !context.permissions.canPublishFeature( + feature, + Array.from(getEnabledEnvironments(feature, environmentsIds)) + ) + ) { context.permissions.throwPermissionError(); } - req.checkPermissions( - "publishFeatures", - feature.project, - getEnabledEnvironments(feature, environmentsIds) - ); + const updatedFeature = await archiveFeature( context, feature, diff --git a/packages/back-end/src/init/config.ts b/packages/back-end/src/init/config.ts index aada5a5a6a9..73eec7b952d 100644 --- a/packages/back-end/src/init/config.ts +++ b/packages/back-end/src/init/config.ts @@ -2,7 +2,6 @@ import { readFileSync, existsSync, statSync } from "fs"; import path from "path"; import { env } from "string-env-interpolation"; import yaml from "js-yaml"; -import { hasReadAccess } from "shared/permissions"; import { EMAIL_ENABLED, ENVIRONMENT, @@ -200,7 +199,7 @@ export function getConfigMetrics( managedBy: "config", }); }) - .filter((m) => hasReadAccess(context.readAccessFilter, m.projects || [])); + .filter((m) => context.permissions.canReadMultiProjectResource(m.projects)); } export function getConfigDimensions( diff --git a/packages/back-end/src/models/ApiKeyModel.ts b/packages/back-end/src/models/ApiKeyModel.ts index c2974fd5324..8db3539527b 100644 --- a/packages/back-end/src/models/ApiKeyModel.ts +++ b/packages/back-end/src/models/ApiKeyModel.ts @@ -3,7 +3,6 @@ import { webcrypto } from "node:crypto"; import mongoose from "mongoose"; import uniqid from "uniqid"; import omit from "lodash/omit"; -import { hasReadAccess } from "shared/permissions"; import { ApiKeyInterface, PublishableApiKey, @@ -48,10 +47,10 @@ type ApiKeyDocument = mongoose.Document & ApiKeyInterface; const ApiKeyModel = mongoose.model("ApiKey", apiKeySchema); const toInterface = (doc: ApiKeyDocument): ApiKeyInterface => { - const asJson = omit( - doc.toJSON({ flattenMaps: true }), - ["__v", "_id"] - ); + const asJson = omit(doc.toJSON({ flattenMaps: true }), [ + "__v", + "_id", + ]); const role = roleForApiKey(asJson) || undefined; return { @@ -284,7 +283,7 @@ export async function getApiKeyByIdOrKey( ): Promise { if (!id && !key) return null; - const { org, readAccessFilter } = context; + const { org } = context; const doc = await ApiKeyModel.findOne( id ? { organization: org.id, id } : { organization: org.id, key } @@ -294,7 +293,9 @@ export async function getApiKeyByIdOrKey( const apiKey = toInterface(doc); - return hasReadAccess(readAccessFilter, apiKey.project) ? apiKey : null; + return context.permissions.canReadSingleProjectResource(apiKey.project) + ? apiKey + : null; } export async function getVisualEditorApiKey( @@ -337,7 +338,7 @@ export async function lookupOrganizationByApiKey( export async function getAllApiKeysByOrganization( context: ReqContext ): Promise { - const { org, readAccessFilter } = context; + const { org } = context; const docs: ApiKeyDocument[] = await ApiKeyModel.find( { @@ -353,7 +354,9 @@ export async function getAllApiKeysByOrganization( return json; }); - return keys.filter((k) => hasReadAccess(readAccessFilter, k.project)); + return keys.filter((k) => { + return context.permissions.canReadSingleProjectResource(k.project); + }); } export async function getFirstPublishableApiKey( diff --git a/packages/back-end/src/models/DataSourceModel.ts b/packages/back-end/src/models/DataSourceModel.ts index 2f1d291039b..b077bc88df1 100644 --- a/packages/back-end/src/models/DataSourceModel.ts +++ b/packages/back-end/src/models/DataSourceModel.ts @@ -1,7 +1,6 @@ import mongoose from "mongoose"; import uniqid from "uniqid"; import { cloneDeep, isEqual } from "lodash"; -import { hasReadAccess } from "shared/permissions"; import { DataSourceInterface, DataSourceParams, @@ -85,7 +84,7 @@ export async function getDataSourcesByOrganization( const datasources = docs.map(toInterface); return datasources.filter((ds) => - hasReadAccess(context.readAccessFilter, ds.projects || []) + context.permissions.canReadMultiProjectResource(ds.projects) ); } @@ -109,7 +108,7 @@ export async function getDataSourceById( const datasource = toInterface(doc); - return hasReadAccess(context.readAccessFilter, datasource.projects) + return context.permissions.canReadMultiProjectResource(datasource.projects) ? datasource : null; } diff --git a/packages/back-end/src/models/ExperimentModel.ts b/packages/back-end/src/models/ExperimentModel.ts index 5667fd2907c..7c5f9936925 100644 --- a/packages/back-end/src/models/ExperimentModel.ts +++ b/packages/back-end/src/models/ExperimentModel.ts @@ -3,7 +3,6 @@ import mongoose, { FilterQuery } from "mongoose"; import uniqid from "uniqid"; import cloneDeep from "lodash/cloneDeep"; import { includeExperimentInPayload, hasVisualChanges } from "shared/util"; -import { hasReadAccess } from "shared/permissions"; import { Changeset, ExperimentInterface, @@ -216,7 +215,7 @@ export const ExperimentModel = mongoose.model( const toInterface = (doc: ExperimentDocument): ExperimentInterface => { const experiment = omit(doc.toJSON(), ["__v", "_id"]); return upgradeExperimentDoc( - (experiment as unknown) as LegacyExperimentInterface + experiment as unknown as LegacyExperimentInterface ); }; @@ -236,7 +235,7 @@ async function findExperiments( const experiments = (await cursor).map(toInterface); return experiments.filter((exp) => - hasReadAccess(context.readAccessFilter, exp.project) + context.permissions.canReadSingleProjectResource(exp.project) ); } @@ -253,7 +252,7 @@ export async function getExperimentById( const experiment = toInterface(doc); - return hasReadAccess(context.readAccessFilter, experiment.project) + return context.permissions.canReadSingleProjectResource(experiment.project) ? experiment : null; } @@ -286,7 +285,7 @@ export async function getExperimentByTrackingKey( const experiment = toInterface(doc); - return hasReadAccess(context.readAccessFilter, experiment.project) + return context.permissions.canReadSingleProjectResource(experiment.project) ? experiment : null; } @@ -469,7 +468,7 @@ export async function getExperimentByIdea( const experiment = toInterface(doc); - return hasReadAccess(context.readAccessFilter, experiment.project) + return context.permissions.canReadSingleProjectResource(experiment.project) ? experiment : null; } @@ -564,7 +563,7 @@ export async function getPastExperimentsByDatasource( ); const experimentsUserCanAccess = experiments.filter((exp) => - hasReadAccess(context.readAccessFilter, exp.project) + context.permissions.canReadSingleProjectResource(exp.project) ); return experimentsUserCanAccess.map((exp) => ({ @@ -661,7 +660,7 @@ export async function getExperimentsForActivityFeed( ); const filteredExperiments = experiments.filter((exp) => - hasReadAccess(context.readAccessFilter, exp.project) + context.permissions.canReadSingleProjectResource(exp.project) ); return filteredExperiments.map((exp) => ({ @@ -688,7 +687,7 @@ const findExperiment = async ({ const experiment = toInterface(doc); - return hasReadAccess(context.readAccessFilter, experiment.project) + return context.permissions.canReadSingleProjectResource(experiment.project) ? experiment : null; }; diff --git a/packages/back-end/src/models/FactMetricModel.ts b/packages/back-end/src/models/FactMetricModel.ts index 2365615bfd2..2193912157c 100644 --- a/packages/back-end/src/models/FactMetricModel.ts +++ b/packages/back-end/src/models/FactMetricModel.ts @@ -31,16 +31,16 @@ export class FactMetricModel extends BaseClass { return this.context.hasPermission("readData", doc.projects || []); } protected canCreate(doc: FactMetricInterface): boolean { - return this.context.permissions.canCreateMetric(doc); + return this.context.permissions.canCreateFactMetric(doc); } protected canUpdate( existing: FactMetricInterface, updates: UpdateProps ): boolean { - return this.context.permissions.canUpdateMetric(existing, updates); + return this.context.permissions.canUpdateFactMetric(existing, updates); } protected canDelete(doc: FactMetricInterface): boolean { - return this.context.permissions.canDeleteMetric(doc); + return this.context.permissions.canDeleteFactMetric(doc); } public static upgradeFactMetricDoc( diff --git a/packages/back-end/src/models/FactTableModel.ts b/packages/back-end/src/models/FactTableModel.ts index 714577a60f3..06f2cdbd97c 100644 --- a/packages/back-end/src/models/FactTableModel.ts +++ b/packages/back-end/src/models/FactTableModel.ts @@ -1,7 +1,6 @@ import mongoose from "mongoose"; import uniqid from "uniqid"; import { omit } from "lodash"; -import { hasReadAccess } from "shared/permissions"; import { CreateFactFilterProps, CreateFactTableProps, @@ -78,7 +77,7 @@ export async function getAllFactTablesForOrganization( const docs = await FactTableModel.find({ organization: context.org.id }); return docs .map((doc) => toInterface(doc)) - .filter((f) => hasReadAccess(context.readAccessFilter, f.projects || [])); + .filter((f) => context.permissions.canReadMultiProjectResource(f.projects)); } export type FactTableMap = Map; @@ -102,7 +101,7 @@ export async function getFactTable( if (!doc) return null; const factTable = toInterface(doc); - if (!hasReadAccess(context.readAccessFilter, factTable.projects || [])) { + if (!context.permissions.canReadMultiProjectResource(factTable.projects)) { return null; } return factTable; diff --git a/packages/back-end/src/models/FeatureModel.ts b/packages/back-end/src/models/FeatureModel.ts index 73201d70ea4..349c1d49541 100644 --- a/packages/back-end/src/models/FeatureModel.ts +++ b/packages/back-end/src/models/FeatureModel.ts @@ -3,7 +3,6 @@ import cloneDeep from "lodash/cloneDeep"; import omit from "lodash/omit"; import isEqual from "lodash/isEqual"; import { MergeResultChanges } from "shared/util"; -import { hasReadAccess } from "shared/permissions"; import { FeatureEnvironment, FeatureInterface, @@ -162,7 +161,7 @@ export async function getAllFeatures( ); return features.filter((feature) => - hasReadAccess(context.readAccessFilter, feature.project) + context.permissions.canReadSingleProjectResource(feature.project) ); } @@ -184,7 +183,7 @@ export async function getAllFeaturesWithLinkedExperiments( const allFeatures = await FeatureModel.find(q); const features = allFeatures.filter((feature) => - hasReadAccess(context.readAccessFilter, feature.project) + context.permissions.canReadSingleProjectResource(feature.project) ); const expIds = new Set( features @@ -210,7 +209,7 @@ export async function getFeature( }); if (!feature) return null; - return hasReadAccess(context.readAccessFilter, feature.project) + return context.permissions.canReadSingleProjectResource(feature.project) ? upgradeFeatureInterface(toInterface(feature)) : null; } @@ -248,7 +247,7 @@ export async function getFeaturesByIds( ).map((m) => upgradeFeatureInterface(toInterface(m))); return features.filter((feature) => - hasReadAccess(context.readAccessFilter, feature.project) + context.permissions.canReadSingleProjectResource(feature.project) ); } diff --git a/packages/back-end/src/models/MetricModel.ts b/packages/back-end/src/models/MetricModel.ts index 91f17abce49..daa5d48616a 100644 --- a/packages/back-end/src/models/MetricModel.ts +++ b/packages/back-end/src/models/MetricModel.ts @@ -1,6 +1,5 @@ import mongoose from "mongoose"; import { ExperimentMetricInterface } from "shared/experiments"; -import { hasReadAccess } from "shared/permissions"; import { LegacyMetricInterface, MetricInterface } from "../../types/metric"; import { getConfigMetrics, usingFileConfig } from "../init/config"; import { upgradeMetricDoc } from "../util/migrations"; @@ -267,7 +266,7 @@ async function findMetrics( }); return metrics.filter((m) => - hasReadAccess(context.readAccessFilter, m.projects || []) + context.permissions.canReadMultiProjectResource(m.projects) ); } @@ -290,7 +289,7 @@ export async function getSampleMetrics(context: ReqContext | ApiReqContext) { organization: context.org.id, }); return docs - .filter((m) => hasReadAccess(context.readAccessFilter, m.projects || [])) + .filter((m) => context.permissions.canReadMultiProjectResource(m.projects)) .map(toInterface); } @@ -330,7 +329,7 @@ export async function getMetricById( if ( !metric || - !hasReadAccess(context.readAccessFilter, metric.projects || []) + !context.permissions.canReadMultiProjectResource(metric.projects) ) { return null; } @@ -368,7 +367,7 @@ export async function getMetricsByIds( }); } return metrics.filter((m) => - hasReadAccess(context.readAccessFilter, m.projects || []) + context.permissions.canReadMultiProjectResource(m.projects) ); } diff --git a/packages/back-end/src/models/ProjectModel.ts b/packages/back-end/src/models/ProjectModel.ts index 9958265ecd8..d47a3ef82c2 100644 --- a/packages/back-end/src/models/ProjectModel.ts +++ b/packages/back-end/src/models/ProjectModel.ts @@ -1,5 +1,4 @@ import { DEFAULT_STATS_ENGINE } from "shared/constants"; -import { hasReadAccess } from "shared/permissions"; import { z } from "zod"; import { ApiProject } from "../../types/openapi"; import { ProjectInterface, ProjectSettings } from "../../types/project"; @@ -38,7 +37,7 @@ interface CreateProjectProps { export class ProjectModel extends BaseClass { protected canRead(doc: ProjectInterface) { - return hasReadAccess(this.context.readAccessFilter, doc.id); + return this.context.permissions.canReadSingleProjectResource(doc.id); } protected canCreate() { diff --git a/packages/back-end/src/models/SdkConnectionModel.ts b/packages/back-end/src/models/SdkConnectionModel.ts index 61fafa2f4b9..3cb92b82ada 100644 --- a/packages/back-end/src/models/SdkConnectionModel.ts +++ b/packages/back-end/src/models/SdkConnectionModel.ts @@ -2,7 +2,6 @@ import mongoose from "mongoose"; import uniqid from "uniqid"; import { z } from "zod"; import { omit } from "lodash"; -import { hasReadAccess } from "shared/permissions"; import { ApiSdkConnection } from "../../types/openapi"; import { CreateSDKConnectionParams, @@ -114,7 +113,7 @@ export async function findSDKConnectionById( if (!doc) return null; const connection = toInterface(doc); - return hasReadAccess(context.readAccessFilter, connection.projects || []) + return context.permissions.canReadMultiProjectResource(connection.projects) ? connection : null; } @@ -128,7 +127,7 @@ export async function findSDKConnectionsByOrganization( const connections = docs.map(toInterface); return connections.filter((conn) => - hasReadAccess(context.readAccessFilter, conn.projects || []) + context.permissions.canReadMultiProjectResource(conn.projects) ); } @@ -174,12 +173,8 @@ function generateSDKConnectionKey() { } export async function createSDKConnection(params: CreateSDKConnectionParams) { - const { - proxyEnabled, - proxyHost, - languages, - ...otherParams - } = createSDKConnectionValidator.parse(params); + const { proxyEnabled, proxyHost, languages, ...otherParams } = + createSDKConnectionValidator.parse(params); // TODO: if using a proxy, try to validate the connection const connection: SDKConnectionInterface = { @@ -239,11 +234,8 @@ export async function editSDKConnection( connection: SDKConnectionInterface, updates: EditSDKConnectionParams ) { - const { - proxyEnabled, - proxyHost, - ...otherChanges - } = editSDKConnectionValidator.parse(updates); + const { proxyEnabled, proxyHost, ...otherChanges } = + editSDKConnectionValidator.parse(updates); let newProxy = { ...connection.proxy, diff --git a/packages/back-end/src/routers/environment/environment.controller.ts b/packages/back-end/src/routers/environment/environment.controller.ts index 722f5ecc222..3eff01dfed1 100644 --- a/packages/back-end/src/routers/environment/environment.controller.ts +++ b/packages/back-end/src/routers/environment/environment.controller.ts @@ -26,14 +26,15 @@ export const putEnvironments = async ( environments: Environment[]; }> ) => { - const { org } = getContextFromReq(req); + const context = getContextFromReq(req); + const { org } = context; const environments = req.body.environments; - req.checkPermissions( - "manageEnvironments", - "", - environments.map((e) => e.id) - ); + environments.forEach((environment) => { + if (!context.permissions.canCreateOrUpdateEnvironment(environment)) { + context.permissions.throwPermissionError(); + } + }); // Add each environment to the list if it doesn't exist yet const updatedEnvironments = environments.reduce((acc, environment) => { @@ -67,9 +68,12 @@ export const postEnvironment = async ( // TODO: Migrate this endpoint to use the new data modelling - https://github.com/growthbook/growthbook/issues/1391 const { environment } = req.body; - req.checkPermissions("manageEnvironments", "", [environment.id]); + const context = getContextFromReq(req); + const { org, environments } = context; - const { org, environments } = getContextFromReq(req); + if (!context.permissions.canCreateOrUpdateEnvironment(environment)) { + context.permissions.throwPermissionError(); + } if (environments.includes(environment.id)) { return res.status(400).json({ diff --git a/packages/back-end/src/routers/fact-table/fact-table.controller.ts b/packages/back-end/src/routers/fact-table/fact-table.controller.ts index bb64789c195..d52ce0bbc16 100644 --- a/packages/back-end/src/routers/fact-table/fact-table.controller.ts +++ b/packages/back-end/src/routers/fact-table/fact-table.controller.ts @@ -223,7 +223,7 @@ export const postFactFilterTest = async ( throw new Error("Could not find fact table with that id"); } - if (!context.permissions.canUpdateFactTable(factTable, {})) { + if (!context.permissions.canCreateAndUpdateFactFilter(factTable)) { context.permissions.throwPermissionError(); } @@ -257,7 +257,7 @@ export const postFactFilter = async ( throw new Error("Could not find fact table with that id"); } - if (!context.permissions.canUpdateFactTable(factTable, {})) { + if (!context.permissions.canCreateAndUpdateFactFilter(factTable)) { context.permissions.throwPermissionError(); } @@ -286,7 +286,7 @@ export const putFactFilter = async ( throw new Error("Could not find fact table with that id"); } - if (!context.permissions.canUpdateFactTable(factTable, {})) { + if (!context.permissions.canCreateAndUpdateFactFilter(factTable)) { context.permissions.throwPermissionError(); } @@ -307,7 +307,7 @@ export const deleteFactFilter = async ( if (!factTable) { throw new Error("Could not find filter table with that id"); } - if (!context.permissions.canUpdateFactTable(factTable, {})) { + if (!context.permissions.canDeleteFactFilter(factTable)) { context.permissions.throwPermissionError(); } diff --git a/packages/back-end/src/routers/organizations/organizations.controller.ts b/packages/back-end/src/routers/organizations/organizations.controller.ts index 2909d790153..2f8f8e23597 100644 --- a/packages/back-end/src/routers/organizations/organizations.controller.ts +++ b/packages/back-end/src/routers/organizations/organizations.controller.ts @@ -9,7 +9,6 @@ import { getLicenseError, orgHasPremiumFeature, } from "enterprise"; -import { hasReadAccess } from "shared/permissions"; import { experimentHasLinkedChanges } from "shared/util"; import { AuthRequest, @@ -306,12 +305,8 @@ export async function putMemberRole( context.permissions.throwPermissionError(); } const { org, userId } = context; - const { - role, - limitAccessByEnvironment, - environments, - projectRoles, - } = req.body; + const { role, limitAccessByEnvironment, environments, projectRoles } = + req.body; const { id } = req.params; if (id === userId) { @@ -557,12 +552,8 @@ export async function putInviteRole( } const { org } = context; - const { - role, - limitAccessByEnvironment, - environments, - projectRoles, - } = req.body; + const { role, limitAccessByEnvironment, environments, projectRoles } = + req.body; const { key } = req.params; const originalInvites: Invite[] = cloneDeep(org.invites); @@ -652,7 +643,11 @@ export async function getOrganization(req: AuthRequest, res: Response) { } const filteredAttributes = settings?.attributeSchema?.filter((attribute) => - hasReadAccess(context.readAccessFilter, attribute.projects || []) + context.permissions.canReadMultiProjectResource(attribute.projects) + ); + + const filteredEnvironments = settings?.environments?.filter((environment) => + context.permissions.canReadMultiProjectResource(environment.projects) ); // Some other global org data needed by the front-end @@ -705,7 +700,11 @@ export async function getOrganization(req: AuthRequest, res: Response) { freeTrialDate: org.freeTrialDate, discountCode: org.discountCode || "", slackTeam: connections?.slack?.team, - settings: { ...settings, attributeSchema: filteredAttributes }, + settings: { + ...settings, + attributeSchema: filteredAttributes, + environments: filteredEnvironments, + }, autoApproveMembers: org.autoApproveMembers, members: org.members, messages: messages || [], @@ -1028,13 +1027,8 @@ export async function postInvite( } const { org } = context; - const { - email, - role, - limitAccessByEnvironment, - environments, - projectRoles, - } = req.body; + const { email, role, limitAccessByEnvironment, environments, projectRoles } = + req.body; const license = getLicense(); if ( @@ -1230,14 +1224,14 @@ export async function putOrganization( Object.keys(settings).forEach((k: keyof OrganizationSettings) => { if (k === "environments") { // Require permissions for any old environments that changed - const affectedEnvs: Set = new Set(); + const affectedEnvs: Set = new Set(); existingEnvironments.forEach((env) => { const oldHash = JSON.stringify(env); const newHash = JSON.stringify( settings[k]?.find((e) => e.id === env.id) ); if (oldHash !== newHash) { - affectedEnvs.add(env.id); + affectedEnvs.add(env); } if (!newHash && oldHash) { deletedEnvIds.push(env.id); @@ -1248,7 +1242,7 @@ export async function putOrganization( const oldIds = new Set(existingEnvironments.map((env) => env.id) || []); settings[k]?.forEach((env) => { if (!oldIds.has(env.id)) { - affectedEnvs.add(env.id); + affectedEnvs.add(env); } }); @@ -1265,13 +1259,26 @@ export async function putOrganization( } }); - req.checkPermissions( - "manageEnvironments", - "", - Array.from(affectedEnvs) - ); + affectedEnvs.forEach((env) => { + if (!context.permissions.canCreateOrUpdateEnvironment(env)) { + context.permissions.throwPermissionError(); + } + }); + + envsWithModifiedProjects.forEach((env) => { + if (!context.permissions.canCreateOrUpdateEnvironment(env)) { + context.permissions.throwPermissionError(); + } + }); } else if (k === "sdkInstructionsViewed" || k === "visualEditorEnabled") { - req.checkPermissions("manageEnvironments", "", []); + if ( + !context.permissions.canCreateSDKConnection({ + projects: [], + environment: "", + }) + ) { + context.permissions.throwPermissionError(); + } } else if (k === "attributeSchema") { throw new Error( "Not supported: Updating organization attributes not supported via this route." @@ -1494,7 +1501,14 @@ export async function postApiKey( } } } else { - req.checkPermissions("manageEnvironments", project, [environment]); + if ( + !context.permissions.canCreateSDKConnection({ + projects: [project], + environment, + }) + ) { + context.permissions.throwPermissionError(); + } } // Handle user personal access tokens @@ -1581,7 +1595,14 @@ export async function deleteApiKey( throw new Error("You do not have permission to delete this."); } } else { - req.checkPermissions("manageEnvironments", "", [keyObj.environment || ""]); + if ( + !context.permissions.canDeleteSDKConnection({ + projects: [keyObj.project || ""], + environment: keyObj.environment || "", + }) + ) { + context.permissions.throwPermissionError(); + } } if (id) { @@ -1642,7 +1663,7 @@ export async function getWebhooks(req: AuthRequest, res: Response) { res.status(200).json({ status: 200, webhooks: webhooks.filter((webhook) => - hasReadAccess(context.readAccessFilter, webhook.project) + context.permissions.canReadSingleProjectResource(webhook.project) ), }); } @@ -1917,12 +1938,8 @@ export async function addOrphanedUser( const { org } = getContextFromReq(req); const { id } = req.params; - const { - role, - environments, - limitAccessByEnvironment, - projectRoles, - } = req.body; + const { role, environments, limitAccessByEnvironment, projectRoles } = + req.body; // Make sure user exists const user = await findUserById(id); diff --git a/packages/back-end/src/routers/sdk-connection/sdk-connection.controller.ts b/packages/back-end/src/routers/sdk-connection/sdk-connection.controller.ts index d7eab985cca..64fd44136bc 100644 --- a/packages/back-end/src/routers/sdk-connection/sdk-connection.controller.ts +++ b/packages/back-end/src/routers/sdk-connection/sdk-connection.controller.ts @@ -39,12 +39,13 @@ export const postSDKConnection = async ( connection: SDKConnectionInterface; }> ) => { - const { org } = getContextFromReq(req); + const context = getContextFromReq(req); + const { org } = context; const params = req.body; - req.checkPermissions("manageEnvironments", params.projects, [ - params.environment, - ]); + if (!context.permissions.canCreateSDKConnection(params)) { + context.permissions.throwPermissionError(); + } let encryptPayload = false; if (orgHasPremiumFeature(org, "encrypt-features-endpoint")) { @@ -92,11 +93,9 @@ export const putSDKConnection = async ( throw new Error("Could not find SDK Connection"); } - const projects = [...connection.projects, ...(req.body.projects || [])]; - - req.checkPermissions("manageEnvironments", projects, [ - connection.environment, - ]); + if (!context.permissions.canUpdateSDKConnection(connection, req.body)) { + context.permissions.throwPermissionError(); + } let encryptPayload = req.body.encryptPayload || false; const encryptionPermitted = orgHasPremiumFeature( @@ -148,9 +147,9 @@ export const deleteSDKConnection = async ( throw new Error("Could not find SDK Connection"); } - req.checkPermissions("manageEnvironments", connection.projects, [ - connection.environment, - ]); + if (!context.permissions.canDeleteSDKConnection(connection)) { + context.permissions.throwPermissionError(); + } await deleteSDKConnectionById(context.org.id, id); diff --git a/packages/back-end/src/routers/url-redirects/url-redirects.controller.ts b/packages/back-end/src/routers/url-redirects/url-redirects.controller.ts index 91f6ffff246..40d34f255b0 100644 --- a/packages/back-end/src/routers/url-redirects/url-redirects.controller.ts +++ b/packages/back-end/src/routers/url-redirects/url-redirects.controller.ts @@ -137,8 +137,13 @@ export const postURLRedirect = async ( const envs = getAffectedEnvsForExperiment({ experiment, }); - envs.length > 0 && - req.checkPermissions("runExperiments", experiment.project, envs); + + if ( + envs.length > 0 && + !context.permissions.canRunExperiment(experiment, envs) + ) { + context.permissions.throwPermissionError(); + } const urlRedirect = await createURLRedirect({ experiment, @@ -198,8 +203,9 @@ export const putURLRedirect = async ( }; const envs = experiment ? getAffectedEnvsForExperiment({ experiment }) : []; - req.checkPermissions("runExperiments", experiment?.project || "", envs); - + if (!context.permissions.canRunExperiment(experiment || {}, envs)) { + context.permissions.throwPermissionError(); + } await updateURLRedirect({ urlRedirect, experiment, @@ -228,7 +234,9 @@ export const deleteURLRedirect = async ( const experiment = await getExperimentById(context, urlRedirect.experiment); const envs = experiment ? getAffectedEnvsForExperiment({ experiment }) : []; - req.checkPermissions("runExperiments", experiment?.project || "", envs); + if (!context.permissions.canRunExperiment(experiment || {}, envs)) { + context.permissions.throwPermissionError(); + } await deleteURLRedirectById({ urlRedirect, diff --git a/packages/back-end/src/services/context.ts b/packages/back-end/src/services/context.ts index c60f8dce05d..ec33c30a357 100644 --- a/packages/back-end/src/services/context.ts +++ b/packages/back-end/src/services/context.ts @@ -1,9 +1,4 @@ -import { - ReadAccessFilter, - getReadAccessFilter, - Permissions, - userHasPermission, -} from "shared/permissions"; +import { Permissions, userHasPermission } from "shared/permissions"; import { uniq } from "lodash"; import type pino from "pino"; import type { Request } from "express"; @@ -51,7 +46,6 @@ export class ReqContextClass { public role?: MemberRole; public isApiRequest = false; public environments: string[]; - public readAccessFilter: ReadAccessFilter; public auditUser: EventAuditUser; public apiKey?: string; public req?: Request; @@ -123,7 +117,6 @@ export class ReqContextClass { }; } - this.readAccessFilter = getReadAccessFilter(this.userPermissions); this.permissions = new Permissions(this.userPermissions, this.superAdmin); this.initModels(); diff --git a/packages/back-end/test/models/VisualChangesetModel.test.ts b/packages/back-end/test/models/VisualChangesetModel.test.ts index 1a33112716a..1d4a260a9ec 100644 --- a/packages/back-end/test/models/VisualChangesetModel.test.ts +++ b/packages/back-end/test/models/VisualChangesetModel.test.ts @@ -13,10 +13,6 @@ describe("updateVisualChangeset", () => { id: "org_123", name: "org_name", }, - readAccessFilter: { - projects: [], - globalReadAccess: true, - }, }; const experiment = { hasVisualChangesets: true, diff --git a/packages/back-end/test/permissions.test.ts b/packages/back-end/test/permissions.test.ts index 8c5515641f2..1c3013fe373 100644 --- a/packages/back-end/test/permissions.test.ts +++ b/packages/back-end/test/permissions.test.ts @@ -1,8 +1,4 @@ -import { - getReadAccessFilter, - hasReadAccess, - Permissions, -} from "shared/permissions"; +import { Permissions } from "shared/permissions"; import { getUserPermissions, roleToPermissionMap, @@ -1247,7 +1243,7 @@ describe("Build base user permissions", () => { }); }); -describe("Build user's readAccessPermissions object", () => { +describe("PermissionsUtilClass.canReadSingleProjectResource check for features", () => { const testOrg: OrganizationInterface = { id: "org_sktwi1id9l7z9xkjb", name: "Test Org", @@ -1275,172 +1271,152 @@ describe("Build user's readAccessPermissions object", () => { }, }; - it("user with global no access role should have no read access", async () => { - const userPermissions = getUserPermissions( - "base_user_123", + it("User with global noaccess role shouldn't be able to see any features", async () => { + const permissions = new Permissions( { - ...testOrg, - members: [{ ...testOrg.members[0], role: "noaccess" }], + global: { + permissions: roleToPermissionMap("noaccess", testOrg), + limitAccessByEnvironment: false, + environments: [], + }, + projects: {}, }, - [] + false ); - const readAccessFilter = getReadAccessFilter(userPermissions); - - expect(readAccessFilter).toEqual({ - globalReadAccess: false, - projects: [], - }); - }); - - it("user with global readonly role should have global read access", async () => { - const userPermissions = getUserPermissions( - "base_user_123", + const features: Partial[] = [ { - ...testOrg, - members: [{ ...testOrg.members[0], role: "readonly" }], + id: "test-feature-123", + project: "", }, - [] - ); + ]; - const readAccessFilter = getReadAccessFilter(userPermissions); + const filteredFeatures = features.filter((feature) => + permissions.canReadSingleProjectResource(feature.project) + ); - expect(readAccessFilter).toEqual({ - globalReadAccess: true, - projects: [], - }); + expect(filteredFeatures).toEqual([]); }); - it("user with global readonly role, and project noaccess should have global read access, but the project should have no read access", async () => { - const userPermissions = getUserPermissions( - "base_user_123", + it("User with global noaccess role shouldn't be able to see any features if the feature none of the features have the project property defined", async () => { + const permissions = new Permissions( { - ...testOrg, - members: [ - { - ...testOrg.members[0], - role: "readonly", - projectRoles: [ - { - project: "prj_exl5jr5dl4rbw856", - role: "noaccess", - limitAccessByEnvironment: true, - environments: ["staging"], - }, - ], - }, - ], + global: { + permissions: roleToPermissionMap("noaccess", testOrg), + limitAccessByEnvironment: false, + environments: [], + }, + projects: {}, }, - [] + false ); - const readAccessFilter = getReadAccessFilter(userPermissions); + const features: Partial[] = [ + { + id: "test-feature-123", + }, + ]; + + const filteredFeatures = features.filter((feature) => + permissions.canReadSingleProjectResource(feature.project) + ); - expect(readAccessFilter).toEqual({ - globalReadAccess: true, - projects: [ - { - id: "prj_exl5jr5dl4rbw856", - readAccess: false, - }, - ], - }); + expect(filteredFeatures).toEqual([]); }); - it("user with global noaccess role, and project collaborator should not have global read access, but the project should have read access", async () => { - const userPermissions = getUserPermissions( - "base_user_123", + it("User with global readonly role should be able to see any features", async () => { + const permissions = new Permissions( { - ...testOrg, - members: [ - { - ...testOrg.members[0], - role: "noaccess", - projectRoles: [ - { - project: "prj_exl5jr5dl4rbw856", - role: "collaborator", - limitAccessByEnvironment: true, - environments: ["staging"], - }, - ], - }, - ], + global: { + permissions: roleToPermissionMap("readonly", testOrg), + limitAccessByEnvironment: false, + environments: [], + }, + projects: {}, }, - [] + false ); - const readAccessFilter = getReadAccessFilter(userPermissions); + const features: Partial[] = [ + { + id: "test-feature-123", + project: "", + }, + ]; + + const filteredFeatures = features.filter((feature) => + permissions.canReadSingleProjectResource(feature.project) + ); - expect(readAccessFilter).toEqual({ - globalReadAccess: false, - projects: [ - { - id: "prj_exl5jr5dl4rbw856", - readAccess: true, - }, - ], - }); + expect(filteredFeatures).toEqual([ + { + id: "test-feature-123", + project: "", + }, + ]); }); - it("should build the readAccessFilter correctly for a user with multiple project roles", async () => { - const userPermissions = getUserPermissions( - "base_user_123", + it("User with global noaccess role should be able to see any features with a project, but they should be able to see features in the project they have a readonly role for", async () => { + const permissions = new Permissions( { - ...testOrg, - members: [ - { - ...testOrg.members[0], - role: "noaccess", - projectRoles: [ - { - project: "prj_exl5jr5dl4rbw856", - role: "collaborator", - limitAccessByEnvironment: true, - environments: ["staging"], - }, - { - project: "prj_exl5jr5dl4rbw123", - role: "engineer", - limitAccessByEnvironment: true, - environments: [], - }, - { - project: "prj_exl5jr5dl4rbw456", - role: "engineer", - limitAccessByEnvironment: true, - environments: ["staging"], - }, - ], + global: { + permissions: roleToPermissionMap("noaccess", testOrg), + limitAccessByEnvironment: false, + environments: [], + }, + projects: { + project1: { + permissions: roleToPermissionMap("readonly", testOrg), + limitAccessByEnvironment: false, + environments: [], }, - ], + project2: { + permissions: roleToPermissionMap("readonly", testOrg), + limitAccessByEnvironment: false, + environments: [], + }, + }, }, - [] + false ); - const readAccessFilter = getReadAccessFilter(userPermissions); + const features: Partial[] = [ + { + id: "test-feature-123", + project: "", + }, + { + id: "test-feature-345", + project: "project1", + }, + { + id: "test-feature-567", + project: "project1", + }, + { + id: "test-feature-890", + project: "project3", + }, + ]; - expect(readAccessFilter).toEqual({ - globalReadAccess: false, - projects: [ - { - id: "prj_exl5jr5dl4rbw856", - readAccess: true, - }, - { - id: "prj_exl5jr5dl4rbw123", - readAccess: true, - }, - { - id: "prj_exl5jr5dl4rbw456", - readAccess: true, - }, - ], - }); + const filteredFeatures = features.filter((feature) => + permissions.canReadSingleProjectResource(feature.project) + ); + + expect(filteredFeatures).toEqual([ + { + id: "test-feature-345", + project: "project1", + }, + { + id: "test-feature-567", + project: "project1", + }, + ]); }); }); -describe("hasReadAccess filter", () => { +describe("PermissionsUtilClass.canReadMultiProjectResource check for metrics", () => { const testOrg: OrganizationInterface = { id: "org_sktwi1id9l7z9xkjb", name: "Test Org", @@ -1468,348 +1444,209 @@ describe("hasReadAccess filter", () => { }, }; - it("hasReadAccess should filter out all features for user with global no access role", async () => { - const userPermissions = getUserPermissions( - "base_user_123", - { - ...testOrg, - members: [{ ...testOrg.members[0], role: "noaccess" }], - }, - [] - ); - - const readAccessFilter = getReadAccessFilter(userPermissions); - - const features: Partial[] = [ - { - id: "test-feature-123", - project: "", - }, - ]; - - const filteredFeatures = features.filter((feature) => - hasReadAccess(readAccessFilter, feature.project) - ); - - expect(filteredFeatures).toEqual([]); - }); - - it("hasReadAccess should not filter out all features for user with global readonly role", async () => { - const userPermissions = getUserPermissions( - "base_user_123", + it("User with global noaccess role should be able to see metrics in 'All Projects' aka - an empty projects array, if they have atleast 1 project level role that grants them access", async () => { + const permissions = new Permissions( { - ...testOrg, - members: [{ ...testOrg.members[0], role: "readonly" }], + global: { + permissions: roleToPermissionMap("noaccess", testOrg), + limitAccessByEnvironment: false, + environments: [], + }, + projects: { + project1: { + permissions: roleToPermissionMap("readonly", testOrg), + limitAccessByEnvironment: false, + environments: [], + }, + }, }, - [] + false ); - const readAccessFilter = getReadAccessFilter(userPermissions); - - const features: Partial[] = [ + const metrics: Partial[] = [ { id: "test-feature-123", - project: "", + projects: [], }, ]; - const filteredFeatures = features.filter((feature) => - hasReadAccess(readAccessFilter, feature.project) + const filteredMetrics = metrics.filter((metric) => + permissions.canReadMultiProjectResource(metric.projects) ); - expect(filteredFeatures).toEqual([ + expect(filteredMetrics).toEqual([ { id: "test-feature-123", - project: "", + projects: [], }, ]); }); - it("hasReadAccess should filter out all projects aside from the project the user has collaborator access to", async () => { - const userPermissions = getUserPermissions( - "base_user_123", + it("User with global noaccess role should be able to see metrics in 'All Projects' aka - an undefined projects, if they have atleast 1 project level role that grants them access", async () => { + const permissions = new Permissions( { - ...testOrg, - members: [ - { - ...testOrg.members[0], - role: "noaccess", - projectRoles: [ - { - project: "prj_exl5jr5dl4rbw856", - role: "collaborator", - limitAccessByEnvironment: true, - environments: ["staging"], - }, - ], + global: { + permissions: roleToPermissionMap("noaccess", testOrg), + limitAccessByEnvironment: false, + environments: [], + }, + projects: { + project1: { + permissions: roleToPermissionMap("readonly", testOrg), + limitAccessByEnvironment: false, + environments: [], }, - ], + }, }, - [] + false ); - const readAccessFilter = getReadAccessFilter(userPermissions); - - const features: Partial[] = [ + const metrics: Partial[] = [ { id: "test-feature-123", - project: "", - }, - { - id: "test-feature-456", - project: "prj_exl5jr5dl4rbw856", - }, - { - id: "test-feature-789", - project: "prj_exl5jr5dl4rbw123", }, ]; - const filteredFeatures = features.filter((feature) => - hasReadAccess(readAccessFilter, feature.project) + const filteredMetrics = metrics.filter((metric) => + permissions.canReadMultiProjectResource(metric.projects) ); - expect(filteredFeatures).toEqual([ + expect(filteredMetrics).toEqual([ { - id: "test-feature-456", - project: "prj_exl5jr5dl4rbw856", + id: "test-feature-123", }, ]); + expect(filteredMetrics.length).toEqual(1); }); - it("hasReadAccess should filter out all projects aside from the project the user has collaborator access to", async () => { - const userPermissions = getUserPermissions( - "base_user_123", + it("User with global noaccess role should not be able to see metrics in 'All Projects' aka - an undefined projects, if they don't have atleast 1 project level role that grants them access", async () => { + const permissions = new Permissions( { - ...testOrg, - members: [ - { - ...testOrg.members[0], - role: "collaborator", - projectRoles: [ - { - project: "prj_exl5jr5dl4rbw856", - role: "noaccess", - limitAccessByEnvironment: true, - environments: ["staging"], - }, - ], - }, - ], + global: { + permissions: roleToPermissionMap("noaccess", testOrg), + limitAccessByEnvironment: false, + environments: [], + }, + projects: {}, }, - [] + false ); - const readAccessFilter = getReadAccessFilter(userPermissions); - - const features: Partial[] = [ + const metrics: Partial[] = [ { id: "test-feature-123", - project: "", - }, - { - id: "test-feature-456", - project: "prj_exl5jr5dl4rbw856", - }, - { - id: "test-feature-789", - project: "prj_exl5jr5dl4rbw123", }, ]; - const filteredFeatures = features.filter((feature) => - hasReadAccess(readAccessFilter, feature.project) + const filteredMetrics = metrics.filter((metric) => + permissions.canReadMultiProjectResource(metric.projects) ); - expect(filteredFeatures).toEqual([ - { - id: "test-feature-123", - project: "", - }, - { - id: "test-feature-789", - project: "prj_exl5jr5dl4rbw123", - }, - ]); + expect(filteredMetrics).toEqual([]); + expect(filteredMetrics.length).toEqual(0); }); - // e.g. user's global role is noaccess, but they have project-level permissions for a singular project - if their collaborator permissions include atleast 1 project on the metric, they should get access - it("hasReadAccess should allow access if user has readAccess for atleast 1 project on an experiment", async () => { - const userPermissions = getUserPermissions( - "base_user_123", + it("User with global noaccess role shouldn't be able to see metrics if the metrics are exlusively in projects they don't have a specific role that grants them read access for", async () => { + const permissions = new Permissions( { - ...testOrg, - members: [ - { - ...testOrg.members[0], - role: "noaccess", - projectRoles: [ - { - project: "prj_exl5jr5dl4rbw856", - role: "collaborator", - limitAccessByEnvironment: true, - environments: ["staging"], - }, - ], - }, - ], + global: { + permissions: roleToPermissionMap("noaccess", testOrg), + limitAccessByEnvironment: false, + environments: [], + }, + projects: {}, }, - [] + false ); - const readAccessFilter = getReadAccessFilter(userPermissions); - const metrics: Partial[] = [ { id: "test-feature-123", - projects: [], - }, - { - id: "test-feature-456", - projects: ["prj_exl5jr5dl4rbw856", "prj_exl5jr5dl4rbw123"], - }, - { - id: "test-feature-789", - projects: ["prj_exl5jr5dl4rbw123"], + projects: ["project123"], }, ]; const filteredMetrics = metrics.filter((metric) => - hasReadAccess(readAccessFilter, metric.projects || []) + permissions.canReadMultiProjectResource(metric.projects) ); - expect(filteredMetrics).toEqual([ - { - id: "test-feature-123", - projects: [], - }, - { - id: "test-feature-456", - projects: ["prj_exl5jr5dl4rbw856", "prj_exl5jr5dl4rbw123"], - }, - ]); + expect(filteredMetrics).toEqual([]); }); - // The user's global role is collaborator, but they have project-level permissions for two projects that take away readaccess. If a metric is in both of the projects the user has noaccess role, AND a project the user doesn't have a specific permission for, the user should be able to access it due to their global permission - it("hasReadAccess should not allow access if user has ", async () => { - const userPermissions = getUserPermissions( - "base_user_123", + it("User with global noaccess role should be able to see metrics if the user as readData permission for atleast one of the metrics projects", async () => { + const permissions = new Permissions( { - ...testOrg, - members: [ - { - ...testOrg.members[0], - role: "collaborator", - projectRoles: [ - { - project: "prj_exl5jr5dl4rbw856", - role: "noaccess", - limitAccessByEnvironment: true, - environments: ["staging"], - }, - { - project: "prj_exl5jr5dl4rbw123", - role: "noaccess", - limitAccessByEnvironment: true, - environments: ["staging"], - }, - ], + global: { + permissions: roleToPermissionMap("noaccess", testOrg), + limitAccessByEnvironment: false, + environments: [], + }, + projects: { + project123: { + permissions: roleToPermissionMap("readonly", testOrg), + limitAccessByEnvironment: false, + environments: [], }, - ], + }, }, - [] + false ); - const readAccessFilter = getReadAccessFilter(userPermissions); - const metrics: Partial[] = [ { id: "test-feature-123", - projects: [], - }, - { - id: "test-feature-456", - projects: ["prj_exl5jr5dl4rbw856", "prj_exl5jr5dl4rbw123", "abc123"], - }, - { - id: "test-feature-789", - projects: ["prj_exl5jr5dl4rbw123"], + projects: ["project123", "project345"], }, ]; const filteredMetrics = metrics.filter((metric) => - hasReadAccess(readAccessFilter, metric.projects || []) + permissions.canReadMultiProjectResource(metric.projects) ); expect(filteredMetrics).toEqual([ { id: "test-feature-123", - projects: [], - }, - { - id: "test-feature-456", - projects: ["prj_exl5jr5dl4rbw856", "prj_exl5jr5dl4rbw123", "abc123"], + projects: ["project123", "project345"], }, ]); }); - // The user's global role is collaborator, but they have project-level permissions for two projects. If a metric is in both of the projects the user has a noaccess role for, the user shouldn't be able to access it - it("hasReadAccess should not allow access if user has ", async () => { - const userPermissions = getUserPermissions( - "base_user_123", + it("User with global readonly role should not be able to see metrics if the user has noaccess permission for every one of the metrics projects", async () => { + const permissions = new Permissions( { - ...testOrg, - members: [ - { - ...testOrg.members[0], - role: "collaborator", - projectRoles: [ - { - project: "prj_exl5jr5dl4rbw856", - role: "noaccess", - limitAccessByEnvironment: true, - environments: ["staging"], - }, - { - project: "prj_exl5jr5dl4rbw123", - role: "noaccess", - limitAccessByEnvironment: true, - environments: ["staging"], - }, - ], + global: { + permissions: roleToPermissionMap("readonly", testOrg), + limitAccessByEnvironment: false, + environments: [], + }, + projects: { + project123: { + permissions: roleToPermissionMap("noaccess", testOrg), + limitAccessByEnvironment: false, + environments: [], }, - ], + project345: { + permissions: roleToPermissionMap("noaccess", testOrg), + limitAccessByEnvironment: false, + environments: [], + }, + }, }, - [] + false ); - const readAccessFilter = getReadAccessFilter(userPermissions); - const metrics: Partial[] = [ { id: "test-feature-123", - projects: [], - }, - { - id: "test-feature-456", - projects: ["prj_exl5jr5dl4rbw856", "prj_exl5jr5dl4rbw123"], - }, - { - id: "test-feature-789", - projects: ["prj_exl5jr5dl4rbw123"], + projects: ["project123", "project345"], }, ]; const filteredMetrics = metrics.filter((metric) => - hasReadAccess(readAccessFilter, metric.projects || []) + permissions.canReadMultiProjectResource(metric.projects) ); - expect(filteredMetrics).toEqual([ - { - id: "test-feature-123", - projects: [], - }, - ]); + expect(filteredMetrics).toEqual([]); }); }); diff --git a/packages/back-end/types/organization.d.ts b/packages/back-end/types/organization.d.ts index ab9710e2f27..559a6c365e9 100644 --- a/packages/back-end/types/organization.d.ts +++ b/packages/back-end/types/organization.d.ts @@ -10,9 +10,10 @@ import { AttributionModel, ImplementationType } from "./experiment"; import type { PValueCorrection, StatsEngine } from "./stats"; import { MetricCappingSettings, MetricWindowSettings } from "./fact-table"; -export type EnvScopedPermission = typeof ENV_SCOPED_PERMISSIONS[number]; -export type ProjectScopedPermission = typeof PROJECT_SCOPED_PERMISSIONS[number]; -export type GlobalPermission = typeof GLOBAL_PERMISSIONS[number]; +export type EnvScopedPermission = (typeof ENV_SCOPED_PERMISSIONS)[number]; +export type ProjectScopedPermission = + (typeof PROJECT_SCOPED_PERMISSIONS)[number]; +export type GlobalPermission = (typeof GLOBAL_PERMISSIONS)[number]; export type Permission = | GlobalPermission @@ -124,7 +125,7 @@ export interface Namespaces { export type SDKAttributeFormat = "" | "version"; -export type SDKAttributeType = typeof attributeDataTypes[number]; +export type SDKAttributeType = (typeof attributeDataTypes)[number]; export type SDKAttribute = { property: string; @@ -197,6 +198,8 @@ export interface OrganizationSettings { codeRefsBranchesToFilter?: string[]; codeRefsPlatformUrl?: string; powerCalculatorEnabled?: boolean; + featureKeyExample?: string; // Example Key of feature flag (e.g. "feature-20240201-name") + featureRegexValidator?: string; // Regex to validate feature flag name (e.g. ^.+-\d{8}-.+$) } export interface SubscriptionQuote { diff --git a/packages/front-end/components/Experiment/AddExperimentModal.tsx b/packages/front-end/components/Experiment/AddExperimentModal.tsx index cfc71f89a73..e037756d8d4 100644 --- a/packages/front-end/components/Experiment/AddExperimentModal.tsx +++ b/packages/front-end/components/Experiment/AddExperimentModal.tsx @@ -3,7 +3,7 @@ import { IconType } from "react-icons"; import { GoBeaker, GoGraph } from "react-icons/go"; import clsx from "clsx"; import track from "@/services/track"; -import usePermissions from "@/hooks/usePermissions"; +import usePermissionsUtil from "@/hooks/usePermissionsUtils"; import { useDefinitions } from "@/services/DefinitionsContext"; import Modal from "@/components/Modal"; import styles from "./AddExperimentModal.module.scss"; @@ -52,10 +52,9 @@ const AddExperimentModal: FC<{ }> = ({ onClose, source }) => { const { project } = useDefinitions(); - const permissions = usePermissions(); - const hasRunExperimentsPermission = permissions.check( - "runExperiments", - project, + const permissionsUtil = usePermissionsUtil(); + const hasRunExperimentsPermission = permissionsUtil.canRunExperiment( + { project }, [] ); diff --git a/packages/front-end/components/Experiment/AnalysisForm.tsx b/packages/front-end/components/Experiment/AnalysisForm.tsx index 6fda369bc7e..353fa5d5540 100644 --- a/packages/front-end/components/Experiment/AnalysisForm.tsx +++ b/packages/front-end/components/Experiment/AnalysisForm.tsx @@ -19,7 +19,7 @@ import useOrgSettings from "@/hooks/useOrgSettings"; import PremiumTooltip from "@/components/Marketing/PremiumTooltip"; import { useUser } from "@/services/UserContext"; import { hasFileConfig } from "@/services/env"; -import usePermissions from "@/hooks/usePermissions"; +import usePermissionsUtil from "@/hooks/usePermissionsUtils"; import { GBSequential } from "@/components/Icons"; import StatsEngineSelect from "@/components/Settings/forms/StatsEngineSelect"; import Modal from "@/components/Modal"; @@ -65,14 +65,13 @@ const AnalysisForm: FC<{ const { organization, hasCommercialFeature } = useUser(); - const permissions = usePermissions(); + const permissionsUtil = usePermissionsUtil(); const orgSettings = useOrgSettings(); const hasOverrideMetricsFeature = hasCommercialFeature("override-metrics"); - const [hasMetricOverrideRiskError, setHasMetricOverrideRiskError] = useState( - false - ); + const [hasMetricOverrideRiskError, setHasMetricOverrideRiskError] = + useState(false); const [upgradeModal, setUpgradeModal] = useState(false); const pid = experiment?.project; @@ -83,14 +82,13 @@ const AnalysisForm: FC<{ project: project ?? undefined, }); - const hasSequentialTestingFeature = hasCommercialFeature( - "sequential-testing" - ); + const hasSequentialTestingFeature = + hasCommercialFeature("sequential-testing"); let canRunExperiment = !experiment.archived; const envs = getAffectedEnvsForExperiment({ experiment }); if (envs.length > 0) { - if (!permissions.check("runExperiments", experiment.project, envs)) { + if (!permissionsUtil.canRunExperiment(experiment, envs)) { canRunExperiment = false; } } @@ -143,10 +141,8 @@ const AnalysisForm: FC<{ }, }); - const [ - usingSequentialTestingDefault, - setUsingSequentialTestingDefault, - ] = useState(experiment.sequentialTestingEnabled === undefined); + const [usingSequentialTestingDefault, setUsingSequentialTestingDefault] = + useState(experiment.sequentialTestingEnabled === undefined); const setSequentialTestingToDefault = useCallback( (enable: boolean) => { if (enable) { @@ -227,7 +223,8 @@ const AnalysisForm: FC<{ } if (usingSequentialTestingDefault) { // User checked the org default checkbox; ignore form values - body.sequentialTestingEnabled = !!orgSettings.sequentialTestingEnabled; + body.sequentialTestingEnabled = + !!orgSettings.sequentialTestingEnabled; body.sequentialTestingTuningParameter = orgSettings.sequentialTestingTuningParameter ?? DEFAULT_SEQUENTIAL_TESTING_TUNING_PARAMETER; @@ -657,7 +654,7 @@ const AnalysisForm: FC<{ + form as unknown as UseFormReturn } disabled={!hasOverrideMetricsFeature} setHasMetricOverrideRiskError={(v: boolean) => diff --git a/packages/front-end/components/Experiment/Results.tsx b/packages/front-end/components/Experiment/Results.tsx index 8fd688863c2..32e884069a3 100644 --- a/packages/front-end/components/Experiment/Results.tsx +++ b/packages/front-end/components/Experiment/Results.tsx @@ -2,7 +2,7 @@ import { ExperimentInterfaceStringDates } from "back-end/types/experiment"; import React, { FC, useEffect } from "react"; import dynamic from "next/dynamic"; import { DifferenceType, StatsEngine } from "back-end/types/stats"; -import { getValidDate, ago } from "shared/dates"; +import { getValidDate, ago, relativeDate } from "shared/dates"; import { DEFAULT_STATS_ENGINE } from "shared/constants"; import { ExperimentMetricInterface } from "shared/experiments"; import { ExperimentSnapshotInterface } from "@back-end/types/experiment-snapshot"; @@ -130,10 +130,10 @@ const Results: FC<{ reason: m.computedSettings?.regressionAdjustmentReason || "", regressionAdjustmentDays: m.computedSettings?.regressionAdjustmentDays || 0, - regressionAdjustmentEnabled: !!m.computedSettings - ?.regressionAdjustmentEnabled, - regressionAdjustmentAvailable: !!m.computedSettings - ?.regressionAdjustmentAvailable, + regressionAdjustmentEnabled: + !!m.computedSettings?.regressionAdjustmentEnabled, + regressionAdjustmentAvailable: + !!m.computedSettings?.regressionAdjustmentAvailable, })) || []; const showCompactResults = @@ -229,9 +229,13 @@ const Results: FC<{ "Make sure your experiment is tracking properly."} {snapshot && phaseAgeMinutes < 120 && - "It was just started " + - ago(experiment.phases[phase]?.dateStarted ?? "") + - ". Give it a little longer and click the 'Update' button above to check again."} + (phaseAgeMinutes < 0 + ? "This experiment will start " + + relativeDate(experiment.phases[phase]?.dateStarted ?? "") + + ". Wait until it's been running for a little while and click the 'Update' button above to check again." + : "It was just started " + + ago(experiment.phases[phase]?.dateStarted ?? "") + + ". Give it a little longer and click the 'Update' button above to check again.")} {!snapshot && datasource && permissionsUtil.canRunExperimentQueries(datasource) && diff --git a/packages/front-end/components/Experiment/TabbedPage/ExperimentHeader.tsx b/packages/front-end/components/Experiment/TabbedPage/ExperimentHeader.tsx index 17a3be50d57..f0781cdd968 100644 --- a/packages/front-end/components/Experiment/TabbedPage/ExperimentHeader.tsx +++ b/packages/front-end/components/Experiment/TabbedPage/ExperimentHeader.tsx @@ -20,7 +20,6 @@ import ConfirmButton from "@/components/Modal/ConfirmButton"; import DeleteButton from "@/components/DeleteButton/DeleteButton"; import TabButtons from "@/components/Tabs/TabButtons"; import TabButton from "@/components/Tabs/TabButton"; -import usePermissions from "@/hooks/usePermissions"; import HeaderWithEdit from "@/components/Layout/HeaderWithEdit"; import Modal from "@/components/Modal"; import { useScrollPosition } from "@/hooks/useScrollPosition"; @@ -102,7 +101,6 @@ export default function ExperimentHeader({ }: Props) { const { apiCall } = useAuth(); const router = useRouter(); - const permissions = usePermissions(); const permissionsUtil = usePermissionsUtil(); const { getDatasourceById } = useDefinitions(); const dataSource = getDatasourceById(experiment.datasource); @@ -140,7 +138,7 @@ export default function ExperimentHeader({ let hasRunExperimentsPermission = true; const envs = getAffectedEnvsForExperiment({ experiment }); if (envs.length > 0) { - if (!permissions.check("runExperiments", experiment.project, envs)) { + if (!permissionsUtil.canRunExperiment(experiment, envs)) { hasRunExperimentsPermission = false; } } diff --git a/packages/front-end/components/Experiment/TabbedPage/Implementation.tsx b/packages/front-end/components/Experiment/TabbedPage/Implementation.tsx index 2320a64e62a..c297230fe1d 100644 --- a/packages/front-end/components/Experiment/TabbedPage/Implementation.tsx +++ b/packages/front-end/components/Experiment/TabbedPage/Implementation.tsx @@ -4,7 +4,6 @@ import { } from "back-end/types/experiment"; import { VisualChangesetInterface } from "back-end/types/visual-changeset"; import { URLRedirectInterface } from "@back-end/types/url-redirect"; -import usePermissions from "@/hooks/usePermissions"; import AddLinkedChanges from "@/components/Experiment/LinkedChanges/AddLinkedChanges"; import RedirectLinkedChanges from "@/components/Experiment/LinkedChanges/RedirectLinkedChanges"; import FeatureLinkedChanges from "@/components/Experiment/LinkedChanges/FeatureLinkedChanges"; @@ -37,7 +36,6 @@ export default function Implementation({ }: Props) { const phases = experiment.phases || []; - const permissions = usePermissions(); const permissionsUtil = usePermissionsUtil(); const canEditExperiment = @@ -45,8 +43,7 @@ export default function Implementation({ permissionsUtil.canViewExperimentModal(experiment.project); const hasVisualEditorPermission = - canEditExperiment && - permissions.check("runExperiments", experiment.project, []); + canEditExperiment && permissionsUtil.canRunExperiment(experiment, []); const canAddLinkedChanges = hasVisualEditorPermission && experiment.status === "draft"; diff --git a/packages/front-end/components/Experiment/VisualEditorScriptMissing.tsx b/packages/front-end/components/Experiment/VisualEditorScriptMissing.tsx deleted file mode 100644 index 35eb109a20e..00000000000 --- a/packages/front-end/components/Experiment/VisualEditorScriptMissing.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { ApiKeyInterface } from "back-end/types/apikey"; -import { useEffect, useState } from "react"; -import { useAuth } from "@/services/auth"; -import usePermissions from "@/hooks/usePermissions"; -import LoadingOverlay from "@/components/LoadingOverlay"; -import VisualEditorInstructions from "@/components/Settings/VisualEditorInstructions"; - -export default function VisualEditorScriptMissing({ - onSuccess, - url, - changeUrl, -}: { - onSuccess: () => void; - changeUrl: () => void; - url?: string; -}) { - const { apiCall } = useAuth(); - const permissions = usePermissions(); - const [apiKeys, setApiKeys] = useState([]); - const [ready, setReady] = useState(false); - const [error, setError] = useState(""); - - async function refreshApiKeys() { - const res = await apiCall<{ keys: ApiKeyInterface[] }>(`/keys`, { - method: "GET", - }); - setApiKeys(res.keys.filter((k) => !k.secret)); - } - - useEffect(() => { - if (!permissions.check("manageEnvironments", "", [])) { - setReady(true); - return; - } - refreshApiKeys() - .then(() => { - setReady(true); - }) - .catch((e) => { - setError(e.message); - }); - }, [permissions.check("manageEnvironments", "", [])]); - - if (!ready) { - return ; - } - if (!permissions.check("manageEnvironments", "", [])) { - return ( -
- We were able to load the site, but couldn't communicate with it. - Please ask your organization administrator to configure the Visual - Editor on your website. -
- ); - } - if (error) { - return
{error}
; - } - - return ( -
- - {apiKeys.length > 0 && ( -
- After adding the above scripts:{" "} - { - e.preventDefault(); - onSuccess(); - }} - > - Refresh - -
- )} -
- ); -} diff --git a/packages/front-end/components/FactTables/FactFilterList.tsx b/packages/front-end/components/FactTables/FactFilterList.tsx index 164edc80139..709cca37b0d 100644 --- a/packages/front-end/components/FactTables/FactFilterList.tsx +++ b/packages/front-end/components/FactTables/FactFilterList.tsx @@ -34,7 +34,8 @@ export default function FactFilterList({ factTable }: Props) { searchFields: ["name^3", "description", "value^2"], }); - const canEdit = permissionsUtil.canViewEditFactTableModal(factTable); + const canAddAndEdit = permissionsUtil.canCreateAndUpdateFactFilter(factTable); + const canDelete = permissionsUtil.canDeleteFactFilter(factTable); return ( <> @@ -65,17 +66,19 @@ export default function FactFilterList({ factTable }: Props) {
@@ -105,8 +108,8 @@ export default function FactFilterList({ factTable }: Props) {
- {canEdit && !filter.managedBy && ( - + + {canAddAndEdit && !filter.managedBy ? ( + ) : null} + {canDelete && !filter.managedBy ? ( - - )} + ) : null} + ))} diff --git a/packages/front-end/components/FactTables/FactMetricList.tsx b/packages/front-end/components/FactTables/FactMetricList.tsx index e5ea63ad18b..d4dec9f8d11 100644 --- a/packages/front-end/components/FactTables/FactMetricList.tsx +++ b/packages/front-end/components/FactTables/FactMetricList.tsx @@ -51,7 +51,7 @@ export default function FactMetricList({ factTable }: Props) { searchFields: ["name^3", "description"], }); - const canCreateMetrics = permissionsUtil.canCreateMetric({ + const canCreateMetrics = permissionsUtil.canCreateFactMetric({ projects: factTable.projects, }); diff --git a/packages/front-end/components/Features/DraftModal.tsx b/packages/front-end/components/Features/DraftModal.tsx index 871371b9cc7..94b7d1efd3d 100644 --- a/packages/front-end/components/Features/DraftModal.tsx +++ b/packages/front-end/components/Features/DraftModal.tsx @@ -10,7 +10,6 @@ import { } from "shared/util"; import { getAffectedRevisionEnvs, useEnvironments } from "@/services/features"; import { useAuth } from "@/services/auth"; -import usePermissions from "@/hooks/usePermissions"; import Modal from "@/components/Modal"; import Button from "@/components/Button"; import Field from "@/components/Forms/Field"; @@ -78,7 +77,6 @@ export default function DraftModal({ }: Props) { const allEnvironments = useEnvironments(); const environments = filterEnvironmentsByFeature(allEnvironments, feature); - const permissions = usePermissions(); const permissionsUtil = usePermissionsUtil(); const { apiCall } = useAuth(); @@ -135,9 +133,8 @@ export default function DraftModal({ if (!revision || !mergeResult) return null; - const hasPermission = permissions.check( - "publishFeatures", - feature.project, + const hasPermission = permissionsUtil.canPublishFeature( + feature, getAffectedRevisionEnvs(feature, revision, environments) ); diff --git a/packages/front-end/components/Features/EnvironmentToggle.tsx b/packages/front-end/components/Features/EnvironmentToggle.tsx index a4ff767d672..02250a485d8 100644 --- a/packages/front-end/components/Features/EnvironmentToggle.tsx +++ b/packages/front-end/components/Features/EnvironmentToggle.tsx @@ -2,10 +2,10 @@ import { useState } from "react"; import { FeatureInterface } from "back-end/types/feature"; import { useAuth } from "@/services/auth"; import track from "@/services/track"; -import usePermissions from "@/hooks/usePermissions"; import useOrgSettings from "@/hooks/useOrgSettings"; import Modal from "@/components/Modal"; import Toggle from "@/components/Forms/Toggle"; +import usePermissionsUtil from "@/hooks/usePermissionsUtils"; export interface Props { feature: FeatureInterface; @@ -25,7 +25,7 @@ export default function EnvironmentToggle({ const [toggling, setToggling] = useState(false); const { apiCall } = useAuth(); - const permissions = usePermissions(); + const permissionsUtil = usePermissionsUtil(); id = id || feature.id + "__" + environment; @@ -87,9 +87,7 @@ export default function EnvironmentToggle({ value={env?.enabled ?? false} id={id} disabledMessage="You don't have permission to change features in this environment" - disabled={ - !permissions.check("publishFeatures", feature.project, [environment]) - } + disabled={!permissionsUtil.canPublishFeature(feature, [environment])} setValue={async (on) => { if (toggling) return; if (on && env?.enabled) return; diff --git a/packages/front-end/components/Features/FeatureModal/EnvironmentSelect.tsx b/packages/front-end/components/Features/FeatureModal/EnvironmentSelect.tsx index 0af32208bd3..bd4166e2899 100644 --- a/packages/front-end/components/Features/FeatureModal/EnvironmentSelect.tsx +++ b/packages/front-end/components/Features/FeatureModal/EnvironmentSelect.tsx @@ -3,14 +3,14 @@ import { Environment } from "back-end/types/organization"; import { FeatureEnvironment } from "back-end/types/feature"; import { useDefinitions } from "@/services/DefinitionsContext"; import Toggle from "@/components/Forms/Toggle"; -import usePermissions from "@/hooks/usePermissions"; +import usePermissionsUtil from "@/hooks/usePermissionsUtils"; const EnvironmentSelect: FC<{ environmentSettings: Record; environments: Environment[]; setValue: (env: Environment, enabled: boolean) => void; }> = ({ environmentSettings, environments, setValue }) => { - const permissions = usePermissions(); + const permissionsUtil = usePermissionsUtil(); const { project } = useDefinitions(); return (
@@ -28,7 +28,7 @@ const EnvironmentSelect: FC<{ label={env.id} disabledMessage="You don't have permission to create features in this environment." disabled={ - !permissions.check("publishFeatures", project, [env.id]) + !permissionsUtil.canPublishFeature({ project }, [env.id]) } className="mr-3" value={environmentSettings[env.id].enabled} diff --git a/packages/front-end/components/Features/FeatureModal/FeatureFromExperimentModal.tsx b/packages/front-end/components/Features/FeatureModal/FeatureFromExperimentModal.tsx index 286e77293f4..e66a889c71f 100644 --- a/packages/front-end/components/Features/FeatureModal/FeatureFromExperimentModal.tsx +++ b/packages/front-end/components/Features/FeatureModal/FeatureFromExperimentModal.tsx @@ -27,7 +27,6 @@ import { useFeaturesList, } from "@/services/features"; import { useWatching } from "@/services/WatchProvider"; -import usePermissions from "@/hooks/usePermissions"; import MarkdownInput from "@/components/Markdown/MarkdownInput"; import SelectField from "@/components/Forms/SelectField"; import FeatureValueField from "@/components/Features/FeatureValueField"; @@ -72,13 +71,13 @@ const genEnvironmentSettings = ({ project, }: { environments: ReturnType; - permissions: ReturnType; + permissions: ReturnType; project: string; }): Record => { const envSettings: Record = {}; environments.forEach((e) => { - const canPublish = permissions.check("publishFeatures", project, [e.id]); + const canPublish = permissions.canPublishFeature({ project }, [e.id]); const defaultEnabled = canPublish ? e.defaultState ?? true : false; const enabled = canPublish ? defaultEnabled : false; const rules = []; @@ -95,7 +94,7 @@ const genFormDefaultValues = ({ experiment, }: { environments: ReturnType; - permissions: ReturnType; + permissions: ReturnType; project: string; experiment: ExperimentInterfaceStringDates; }): Omit & { @@ -143,13 +142,12 @@ export default function FeatureFromExperimentModal({ allEnvironments, experiment ); - const permissions = usePermissions(); const permissionsUtil = usePermissionsUtil(); const { refreshWatching } = useWatching(); const defaultValues = genFormDefaultValues({ environments, - permissions, + permissions: permissionsUtil, experiment, project, }); diff --git a/packages/front-end/components/Features/FeatureModal/FeatureKeyField.tsx b/packages/front-end/components/Features/FeatureModal/FeatureKeyField.tsx index 371999bb0c9..3575fb3cc8e 100644 --- a/packages/front-end/components/Features/FeatureModal/FeatureKeyField.tsx +++ b/packages/front-end/components/Features/FeatureModal/FeatureKeyField.tsx @@ -1,15 +1,16 @@ import { FC } from "react"; import { UseFormRegisterReturn } from "react-hook-form"; import Field from "@/components/Forms/Field"; +import useOrgSettings from "@/hooks/useOrgSettings"; -const FeatureKeyField: FC<{ keyField: UseFormRegisterReturn }> = ({ - keyField, -}) => ( +const FeatureKeyField: FC<{ + keyField: UseFormRegisterReturn; +}> = ({ keyField }) => ( ; featureToDuplicate?: FeatureInterface; - permissions: ReturnType; + permissions: ReturnType; project: string; }): Record => { const envSettings: Record = {}; environments.forEach((e) => { - const canPublish = permissions.check("publishFeatures", project, [e.id]); + const canPublish = permissions.canPublishFeature({ project }, [e.id]); const defaultEnabled = canPublish ? e.defaultState ?? true : false; const enabled = canPublish ? featureToDuplicate?.environmentSettings?.[e.id]?.enabled ?? @@ -88,12 +87,12 @@ const genEnvironmentSettings = ({ const genFormDefaultValues = ({ environments, - permissions, + permissions: permissionsUtil, featureToDuplicate, project, }: { environments: ReturnType; - permissions: ReturnType; + permissions: ReturnType; featureToDuplicate?: FeatureInterface; project: string; }): Pick< @@ -109,7 +108,7 @@ const genFormDefaultValues = ({ const environmentSettings = genEnvironmentSettings({ environments, featureToDuplicate, - permissions, + permissions: permissionsUtil, project, }); return featureToDuplicate @@ -143,13 +142,12 @@ export default function FeatureModal({ }: Props) { const { project, refreshTags } = useDefinitions(); const environments = useEnvironments(); - const permissions = usePermissions(); const permissionsUtil = usePermissionsUtil(); const { refreshWatching } = useWatching(); const defaultValues = genFormDefaultValues({ environments, - permissions, + permissions: permissionsUtil, featureToDuplicate, project, }); diff --git a/packages/front-end/components/Features/FeaturesHeader.tsx b/packages/front-end/components/Features/FeaturesHeader.tsx index 8e0165355d6..e5b0aace16c 100644 --- a/packages/front-end/components/Features/FeaturesHeader.tsx +++ b/packages/front-end/components/Features/FeaturesHeader.tsx @@ -11,7 +11,6 @@ import { DeleteDemoDatasourceButton } from "@/components/DemoDataSourcePage/Demo import StaleFeatureIcon from "@/components/StaleFeatureIcon"; import DeleteButton from "@/components/DeleteButton/DeleteButton"; import ConfirmButton from "@/components/Modal/ConfirmButton"; -import usePermissions from "@/hooks/usePermissions"; import { getEnabledEnvironments, useEnvironments } from "@/services/features"; import { useAuth } from "@/services/auth"; import { useDefinitions } from "@/services/DefinitionsContext"; @@ -64,7 +63,6 @@ export default function FeaturesHeader({ const [showImplementation, setShowImplementation] = useState(firstFeature); const { organization } = useUser(); - const permissions = usePermissions(); const permissionsUtil = usePermissionsUtil(); const allEnvironments = useEnvironments(); const environments = filterEnvironmentsByFeature(allEnvironments, feature); @@ -92,6 +90,7 @@ export default function FeaturesHeader({ const canEdit = permissionsUtil.canViewFeatureModal(projectId); const enabledEnvs = getEnabledEnvironments(feature, environments); + const canPublish = permissionsUtil.canPublishFeature(feature, enabledEnvs); const isArchived = feature.archived; return ( @@ -155,121 +154,104 @@ export default function FeaturesHeader({ : "Disable stale detection"} )} - {canEdit && - permissions.check( - "publishFeatures", - projectId, - enabledEnvs - ) && ( - { - e.preventDefault(); - setDuplicateModal(true); + {canEdit && canPublish && ( + { + e.preventDefault(); + setDuplicateModal(true); + }} + > + Duplicate + + )} + {canEdit && canPublish && ( + 0} + usePortal={true} + body={ + <> + This feature has{" "} + + {dependents} dependent{dependents !== 1 && "s"} + + . This feature cannot be archived until{" "} + {dependents === 1 ? "it has" : "they have"} been + removed. + + } + > + { + await apiCall(`/feature/${feature.id}/archive`, { + method: "POST", + }); + mutate(); }} - > - Duplicate - - )} - {canEdit && - permissions.check( - "publishFeatures", - projectId, - enabledEnvs - ) && ( - 0} - usePortal={true} - body={ - <> - This feature - has{" "} - - {dependents} dependent{dependents !== 1 && "s"} - - . This feature cannot be archived until{" "} - {dependents === 1 ? "it has" : "they have"} been - removed. - + modalHeader={ + isArchived ? "Unarchive Feature" : "Archive Feature" } - > - { - await apiCall(`/feature/${feature.id}/archive`, { - method: "POST", - }); - mutate(); - }} - modalHeader={ - isArchived ? "Unarchive Feature" : "Archive Feature" - } - confirmationText={ - isArchived ? ( - <> -

- Are you sure you want to continue? This will - make the current feature active again. -

- - ) : ( - <> -

- Are you sure you want to continue? This will - make the current feature inactive. It will not - be included in API responses or Webhook - payloads. -

- - ) - } - cta={isArchived ? "Unarchive" : "Archive"} - ctaColor="danger" - disabled={dependents > 0} - > - -
-
- )} - {canEdit && - permissions.check( - "publishFeatures", - projectId, - enabledEnvs - ) && ( - 0} - usePortal={true} - body={ - <> - This feature - has{" "} - - {dependents} dependent{dependents !== 1 && "s"} - - . This feature cannot be deleted until{" "} - {dependents === 1 ? "it has" : "they have"} been - removed. - + confirmationText={ + isArchived ? ( + <> +

+ Are you sure you want to continue? This will + make the current feature active again. +

+ + ) : ( + <> +

+ Are you sure you want to continue? This will + make the current feature inactive. It will not + be included in API responses or Webhook + payloads. +

+ + ) } + cta={isArchived ? "Unarchive" : "Archive"} + ctaColor="danger" + disabled={dependents > 0} > - { - await apiCall(`/feature/${feature.id}`, { - method: "DELETE", - }); - router.push("/features"); - }} - className="dropdown-item text-danger" - text="Delete" - disabled={dependents > 0} - /> -
- )} + +
+
+ )} + {canEdit && canPublish && ( + 0} + usePortal={true} + body={ + <> + This feature has{" "} + + {dependents} dependent{dependents !== 1 && "s"} + + . This feature cannot be deleted until{" "} + {dependents === 1 ? "it has" : "they have"} been + removed. + + } + > + { + await apiCall(`/feature/${feature.id}`, { + method: "DELETE", + }); + router.push("/features"); + }} + className="dropdown-item text-danger" + text="Delete" + disabled={dependents > 0} + /> + + )}
@@ -305,37 +287,31 @@ export default function FeaturesHeader({ ) : ( None )} - {canEdit && - permissions.check( - "publishFeatures", - projectId, - enabledEnvs - ) && ( - 0} - body={ - <> - This feature - has{" "} - - {dependents} dependent{dependents !== 1 && "s"} - - . The project cannot be changed until{" "} - {dependents === 1 ? "it has" : "they have"} been - removed. - - } + {canEdit && canPublish && ( + 0} + body={ + <> + This feature has{" "} + + {dependents} dependent{dependents !== 1 && "s"} + + . The project cannot be changed until{" "} + {dependents === 1 ? "it has" : "they have"} been + removed. + + } + > + { + dependents === 0 && setEditProjectModal(true); + }} > - { - dependents === 0 && setEditProjectModal(true); - }} - > - - - - )} + + + + )} )} diff --git a/packages/front-end/components/Features/FeaturesOverview.tsx b/packages/front-end/components/Features/FeaturesOverview.tsx index 9afc007c069..012cd3c7189 100644 --- a/packages/front-end/components/Features/FeaturesOverview.tsx +++ b/packages/front-end/components/Features/FeaturesOverview.tsx @@ -56,7 +56,6 @@ import Tab from "@/components/Tabs/Tab"; import Modal from "@/components/Modal"; import DraftModal from "@/components/Features/DraftModal"; import RevisionDropdown from "@/components/Features/RevisionDropdown"; -import usePermissions from "@/hooks/usePermissions"; import DiscussionThread from "@/components/DiscussionThread"; import EditOwnerModal from "@/components/Owner/EditOwnerModal"; import Tooltip from "@/components/Tooltip/Tooltip"; @@ -133,7 +132,6 @@ export default function FeaturesOverview({ i: number; } | null>(null); const [showDependents, setShowDependents] = useState(false); - const permissions = usePermissions(); const permissionsUtil = usePermissionsUtil(); const [revertIndex, setRevertIndex] = useState(0); @@ -221,15 +219,13 @@ export default function FeaturesOverview({ prereqStates && Object.values(prereqStates).some((s) => s.state === "conditional"); - const hasPrerequisitesCommercialFeature = hasCommercialFeature( - "prerequisites" - ); + const hasPrerequisitesCommercialFeature = + hasCommercialFeature("prerequisites"); const currentVersion = version || baseFeature.version; - const { jsonSchema, validationEnabled, schemaDateUpdated } = getValidation( - feature - ); + const { jsonSchema, validationEnabled, schemaDateUpdated } = + getValidation(feature); const baseVersion = revision?.baseVersion || feature.version; const baseRevision = revisions.find((r) => r.version === baseVersion); let requireReviews = false; @@ -277,16 +273,14 @@ export default function FeaturesOverview({ const hasDraftPublishPermission = (approved && - permissions.check( - "publishFeatures", - projectId, + permissionsUtil.canPublishFeature( + feature, getAffectedRevisionEnvs(feature, revision, environments) )) || (isDraft && !requireReviews && - permissions.check( - "publishFeatures", - projectId, + permissionsUtil.canPublishFeature( + feature, getAffectedRevisionEnvs(feature, revision, environments) )); diff --git a/packages/front-end/components/Features/RevertModal.tsx b/packages/front-end/components/Features/RevertModal.tsx index 3485fa81a2d..f223fa61afd 100644 --- a/packages/front-end/components/Features/RevertModal.tsx +++ b/packages/front-end/components/Features/RevertModal.tsx @@ -5,9 +5,9 @@ import isEqual from "lodash/isEqual"; import { filterEnvironmentsByFeature } from "shared/util"; import { getAffectedRevisionEnvs, useEnvironments } from "@/services/features"; import { useAuth } from "@/services/auth"; -import usePermissions from "@/hooks/usePermissions"; import Modal from "@/components/Modal"; import Field from "@/components/Forms/Field"; +import usePermissionsUtil from "@/hooks/usePermissionsUtils"; import { ExpandableDiff } from "./DraftModal"; export interface Props { @@ -27,7 +27,7 @@ export default function RevertModal({ }: Props) { const allEnvironments = useEnvironments(); const environments = filterEnvironmentsByFeature(allEnvironments, feature); - const permissions = usePermissions(); + const permissionsUtil = usePermissionsUtil(); const { apiCall } = useAuth(); @@ -61,9 +61,8 @@ export default function RevertModal({ return diffs; }, [feature, revision, environments]); - const hasPermission = permissions.check( - "publishFeatures", - feature.project, + const hasPermission = permissionsUtil.canPublishFeature( + feature, getAffectedRevisionEnvs(feature, revision, environments) ); diff --git a/packages/front-end/components/Features/SDKConnections/SDKConnectionsList.tsx b/packages/front-end/components/Features/SDKConnections/SDKConnectionsList.tsx index 30c78732818..ae384747edd 100644 --- a/packages/front-end/components/Features/SDKConnections/SDKConnectionsList.tsx +++ b/packages/front-end/components/Features/SDKConnections/SDKConnectionsList.tsx @@ -13,7 +13,7 @@ import clsx from "clsx"; import { useDefinitions } from "@/services/DefinitionsContext"; import LoadingOverlay from "@/components/LoadingOverlay"; import { GBAddCircle, GBHashLock, GBRemoteEvalIcon } from "@/components/Icons"; -import usePermissions from "@/hooks/usePermissions"; +import usePermissionsUtil from "@/hooks/usePermissionsUtils"; import useSDKConnections from "@/hooks/useSDKConnections"; import StatusCircle from "@/components/Helpers/StatusCircle"; import ProjectBadges from "@/components/ProjectBadges"; @@ -30,10 +30,13 @@ export default function SDKConnectionsList() { const [modalOpen, setModalOpen] = useState(false); const environments = useEnvironments(); - const { projects } = useDefinitions(); + const { projects, project } = useDefinitions(); const router = useRouter(); - const permissions = usePermissions(); + const permissionsUtil = usePermissionsUtil(); + + const canCreateSDKConnections = + permissionsUtil.canViewCreateSDKConnectionModal(project); if (error) { return
{error.message}
; @@ -56,7 +59,7 @@ export default function SDKConnectionsList() {

SDK Connections

- {connections.length > 0 ? ( + {connections.length > 0 && canCreateSDKConnections ? (
); } diff --git a/packages/front-end/components/Features/SDKEndpointSelector.tsx b/packages/front-end/components/Features/SDKEndpointSelector.tsx deleted file mode 100644 index 14608db4b0e..00000000000 --- a/packages/front-end/components/Features/SDKEndpointSelector.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { ApiKeyInterface, PublishableApiKey } from "back-end/types/apikey"; -import Link from "next/link"; -import { useEffect } from "react"; -import { FaAngleRight, FaExternalLinkAlt } from "react-icons/fa"; -import useApi from "@/hooks/useApi"; -import usePermissions from "@/hooks/usePermissions"; -import { useAuth } from "@/services/auth"; -import { useDefinitions } from "@/services/DefinitionsContext"; -import { useEnvironments } from "@/services/features"; -import SelectField from "@/components/Forms/SelectField"; -import LoadingSpinner from "@/components/LoadingSpinner"; - -export interface Props { - apiKey: string; - setApiKey: (apiKey: string) => void; -} - -export default function SDKEndpointSelector({ apiKey, setApiKey }: Props) { - const { data, error, mutate } = useApi<{ keys: ApiKeyInterface[] }>("/keys"); - const environments = useEnvironments(); - const { apiCall } = useAuth(); - const { getProjectById, project } = useDefinitions(); - - const keys = (data?.keys || []) - .filter((k) => !k.secret) - .filter((k) => !project || !k.project || k.project === project); - const hasKeys = keys.length > 0; - const hasData = !!data; - const hasError = !!error; - - const permissions = usePermissions(); - - useEffect(() => { - // Default to the first key - let key = keys[0]; - - // If a project is selected, first try to pick a key that's just for that project - if (project) { - const projectKey = keys.find((k) => k.project === project); - if (projectKey) { - key = projectKey; - } - } - - setApiKey(key?.key || ""); - // eslint-disable-next-line - }, [hasData, hasError, project, hasKeys]); - - const createApiKey = async (env: string, proj: string) => { - const res = await apiCall<{ key: PublishableApiKey }>( - `/keys?preferExisting=true`, - { - method: "POST", - body: JSON.stringify({ - description: `${env} Features SDK`, - environment: env, - project: proj, - secret: false, - }), - } - ); - return res.key?.key || ""; - }; - - const envsWithEndpoints = new Set(); - keys.forEach((k) => { - if (k.environment) envsWithEndpoints.add(k.environment); - }); - async function createMissingEndpoints() { - for (let i = 0; i < environments.length; i++) { - if (!envsWithEndpoints.has(environments[i].id)) { - await createApiKey(environments[i].id, ""); - } - } - } - - // Create any missing SDK endpoints for environments - useEffect(() => { - if (!hasData || hasError) return; - createMissingEndpoints() - .catch((e) => { - console.error(e); - }) - .finally(() => { - mutate(); - }); - // eslint-disable-next-line - }, [hasData, hasError]); - - if (error) return null; - - if (!data) { - return ( -
- Loading SDK Endpoints... -
- ); - } - - if (!keys.length) return null; - - const keyMap = new Map(keys.map((k) => [k.key, k])); - - return ( -
- -
-
- { - return { - value: k.key, - label: `${k.environment} | ${k.description}`, - }; - })} - formatOptionLabel={({ value }) => { - const key = keyMap.get(value); - const env = key?.environment || "production"; - return ( -
- {getProjectById(key?.project || "")?.name || "All Projects"}{" "} - {env} - {key?.description && key.description !== env && ( - - {key.description} - - )} -
- ); - }} - /> -
- - {permissions.check("manageEnvironments", "", []) && ( -
- - Manage environments and endpoints - - -
- )} -
-
- ); -} diff --git a/packages/front-end/components/Features/SDKEndpoints.tsx b/packages/front-end/components/Features/SDKEndpoints.tsx index 50f64684715..54638fc1ab1 100644 --- a/packages/front-end/components/Features/SDKEndpoints.tsx +++ b/packages/front-end/components/Features/SDKEndpoints.tsx @@ -2,7 +2,7 @@ import { FC, useState } from "react"; import { ApiKeyInterface } from "back-end/types/apikey"; import { FaExclamationTriangle, FaKey } from "react-icons/fa"; import { useAuth } from "@/services/auth"; -import usePermissions from "@/hooks/usePermissions"; +import usePermissionsUtil from "@/hooks/usePermissionsUtils"; import { useDefinitions } from "@/services/DefinitionsContext"; import { useEnvironments } from "@/services/features"; import DeleteButton from "@/components/DeleteButton/DeleteButton"; @@ -24,12 +24,14 @@ const SDKEndpoints: FC<{ const environments = useEnvironments(); - const permissions = usePermissions(); + const permissionsUtil = usePermissionsUtil(); const publishableKeys = keys .filter((k) => !k.secret) .filter((k) => !project || !k.project || k.project === project); - const canManageKeys = permissions.check("manageEnvironments", "", []); + + const canCreateKeys = + permissionsUtil.canViewCreateSDKConnectionModal(project); const envCounts = new Map(); publishableKeys.forEach((k) => { @@ -43,7 +45,7 @@ const SDKEndpoints: FC<{ return (
- {open && canManageKeys && ( + {open && ( setOpen(false)} onCreate={mutate} @@ -65,7 +67,7 @@ const SDKEndpoints: FC<{ Description Endpoint Encrypted? - {canManageKeys && } + @@ -73,6 +75,15 @@ const SDKEndpoints: FC<{ const env = key.environment ?? "production"; const endpoint = getApiBaseUrl() + "/api/features/" + key.key; const envExists = environments?.some((e) => e.id === env); + const canManage = permissionsUtil.canCreateSDKConnection({ + projects: [key.project || ""], + environment: key.environment || "", + }); + + const canDelete = permissionsUtil.canDeleteSDKConnection({ + projects: [key.project || ""], + environment: key.environment || "", + }); return ( @@ -102,7 +113,7 @@ const SDKEndpoints: FC<{ {endpoint} - {canManageKeys && key.encryptSDK ? ( + {canManage && key.encryptSDK ? ( { @@ -124,9 +135,9 @@ const SDKEndpoints: FC<{
No
)} - {canManageKeys && ( - - + + + {canDelete ? ( { await apiCall(`/keys`, { @@ -142,16 +153,16 @@ const SDKEndpoints: FC<{ displayName="SDK Endpoint" text="Delete endpoint" /> - - - )} + ) : null} + + ); })} )} - {canManageKeys && ( + {canCreateKeys && (
- +
+ + +

+ + When creating a new feature, this example will be shown. Only + letters, numbers, and the characters _, -, ., :, and | allowed. No + spaces. + +

+
+
+ + +

+ + When using the create feature modal, it will validate the feature + key against this regex. This will not block API feature creation, + and is used to enforce naming conventions at some companies. + +

+
{canCreateWebhooks ? ( - - + + +

+ SDK Webhooks will automatically notify any + changes affecting this SDK. For instance, modifying a feature + or AB test will prompt the webhook to fire. +

+ + } > - - + + What is this? - Add Webhook - -
+ + ) : null} - -

- SDK Webhooks will automatically notify any - changes affecting this SDK. For instance, modifying a feature or - AB test will prompt the webhook to fire. -

- - } - > - - What is this? - -
); diff --git a/packages/front-end/pages/sdks/[sdkid].tsx b/packages/front-end/pages/sdks/[sdkid].tsx index 3d53d41dabc..4d8bae97970 100644 --- a/packages/front-end/pages/sdks/[sdkid].tsx +++ b/packages/front-end/pages/sdks/[sdkid].tsx @@ -20,7 +20,6 @@ import DeleteButton from "@/components/DeleteButton/DeleteButton"; import MoreMenu from "@/components/Dropdown/MoreMenu"; import { useAuth } from "@/services/auth"; import { useDefinitions } from "@/services/DefinitionsContext"; -import usePermissions from "@/hooks/usePermissions"; import SDKConnectionForm from "@/components/Features/SDKConnections/SDKConnectionForm"; import CodeSnippetModal, { getApiBaseUrl, @@ -36,6 +35,7 @@ import { useEnvironments } from "@/services/features"; import Badge from "@/components/Badge"; import ProjectBadges from "@/components/ProjectBadges"; import SdkWebhooks from "@/pages/sdks/SdkWebhooks"; +import usePermissionsUtil from "@/hooks/usePermissionsUtils"; function ConnectionDot({ left }: { left: boolean }) { return ( @@ -166,11 +166,10 @@ export default function SDKConnectionPage() { const environments = useEnvironments(); const { projects } = useDefinitions(); - const permissions = usePermissions(); + const permissionsUtil = usePermissionsUtil(); - const connection: - | SDKConnectionInterface - | undefined = data?.connections?.find((conn) => conn.id === sdkid); + const connection: SDKConnectionInterface | undefined = + data?.connections?.find((conn) => conn.id === sdkid); const environment = environments.find( (e) => e.id === connection?.environment ); @@ -193,12 +192,6 @@ export default function SDKConnectionPage() { ...disallowedProjectIds, ]; - const hasPermission = connection - ? permissions.check("manageEnvironments", connection.projects, [ - connection.environment, - ]) - : false; - const hasProxy = connection?.proxy?.enabled && !!connection?.proxy?.host; if (error) { @@ -211,6 +204,10 @@ export default function SDKConnectionPage() { return
Invalid SDK Connection id
; } + const canDuplicate = permissionsUtil.canCreateSDKConnection(connection); + const canUpdate = permissionsUtil.canUpdateSDKConnection(connection, {}); + const canDelete = permissionsUtil.canDeleteSDKConnection(connection); + return (
{modalState.mode !== "closed" && ( @@ -231,54 +228,60 @@ export default function SDKConnectionPage() {

{connection.name}

- {hasPermission && ( + {canDelete || canUpdate || canDuplicate ? ( <> - - + ) : null} +
+ + {canDuplicate ? ( + + ) : null} + {canDelete ? ( + { + await apiCall(`/sdk-connections/${connection.id}`, { + method: "DELETE", + }); + mutate(); + router.push(`/sdks`); + }} + /> + ) : null}
- )} + ) : null}
@@ -392,7 +395,7 @@ export default function SDKConnectionPage() { { - const { - refreshOrganization, - settings, - organization, - hasCommercialFeature, - } = useUser(); + const { refreshOrganization, settings, organization, hasCommercialFeature } = + useUser(); const [saveMsg, setSaveMsg] = useState(false); const [originalValue, setOriginalValue] = useState({}); const [cronString, setCronString] = useState(""); - const [ - codeRefsBranchesToFilterStr, - setCodeRefsBranchesToFilterStr, - ] = useState(""); + const [codeRefsBranchesToFilterStr, setCodeRefsBranchesToFilterStr] = + useState(""); const displayCurrency = useCurrency(); const { datasources } = useDefinitions(); @@ -105,7 +99,8 @@ const GeneralSettingsPage = (): React.ReactElement => { regressionAdjustmentEnabled: DEFAULT_REGRESSION_ADJUSTMENT_ENABLED, regressionAdjustmentDays: DEFAULT_REGRESSION_ADJUSTMENT_DAYS, sequentialTestingEnabled: false, - sequentialTestingTuningParameter: DEFAULT_SEQUENTIAL_TESTING_TUNING_PARAMETER, + sequentialTestingTuningParameter: + DEFAULT_SEQUENTIAL_TESTING_TUNING_PARAMETER, powerCalculatorEnabled: false, attributionModel: "firstExposure", displayCurrency, @@ -125,6 +120,8 @@ const GeneralSettingsPage = (): React.ReactElement => { codeReferencesEnabled: false, codeRefsBranchesToFilter: [], codeRefsPlatformUrl: "", + featureKeyExample: "", + featureRegexValidator: "", }, }); const { apiCall } = useAuth(); @@ -255,6 +252,39 @@ const GeneralSettingsPage = (): React.ReactElement => { (value.multipleExposureMinPercent ?? 0.01) / 100, }; + // Make sure the feature key example is valid + if ( + transformedOrgSettings.featureKeyExample && + !transformedOrgSettings.featureKeyExample.match(/^[a-zA-Z0-9_.:|-]+$/) + ) { + throw new Error( + "Feature key examples can only include letters, numbers, hyphens, and underscores." + ); + } + + // If the regex validator exists, then the feature key example must match the regex and be valid. + if (transformedOrgSettings.featureRegexValidator) { + if ( + !transformedOrgSettings.featureKeyExample || + !transformedOrgSettings.featureRegexValidator + ) { + throw new Error( + "Feature key example must not be empty when a regex validator is defined." + ); + } + + const regexValidator = transformedOrgSettings.featureRegexValidator; + if ( + !new RegExp(regexValidator).test( + transformedOrgSettings.featureKeyExample + ) + ) { + throw new Error( + `Feature key example does not match the regex validator. '${transformedOrgSettings.featureRegexValidator}' Example: '${transformedOrgSettings.featureKeyExample}'` + ); + } + } + await apiCall(`/organization`, { method: "PUT", body: JSON.stringify({ diff --git a/packages/shared/src/dates.ts b/packages/shared/src/dates.ts index d7323c50cca..8836f6851e4 100644 --- a/packages/shared/src/dates.ts +++ b/packages/shared/src/dates.ts @@ -3,6 +3,7 @@ import formatDistance from "date-fns/formatDistance"; import differenceInDays from "date-fns/differenceInDays"; import differenceInHours from "date-fns/differenceInHours"; import addMonths from "date-fns/addMonths"; +import formatRelative from "date-fns/formatRelative"; export function date(date: string | Date): string { if (!date) return ""; @@ -12,6 +13,10 @@ export function datetime(date: string | Date): string { if (!date) return ""; return format(getValidDate(date), "PPp"); } +export function relativeDate(date: string | Date): string { + if (!date) return ""; + return formatRelative(getValidDate(date), new Date()); +} export function ago(date: string | Date): string { if (!date) return ""; return formatDistance(getValidDate(date), new Date(), { addSuffix: true }); diff --git a/packages/shared/src/permissions/permissions.utils.ts b/packages/shared/src/permissions/permissions.utils.ts index 677f8cc3398..5d5fee1615e 100644 --- a/packages/shared/src/permissions/permissions.utils.ts +++ b/packages/shared/src/permissions/permissions.utils.ts @@ -128,80 +128,3 @@ export const userHasPermission = ( export function roleSupportsEnvLimit(role: MemberRole): boolean { return ["engineer", "experimenter"].includes(role); } - -export type ReadAccessFilter = { - globalReadAccess: boolean; - projects: { id: string; readAccess: boolean }[]; -}; - -// there are some cases, like in async jobs, where we need to provide the job with full access permission. E.G. updateScheduledFeature -export const FULL_ACCESS_PERMISSIONS: ReadAccessFilter = { - globalReadAccess: true, - projects: [], -}; - -export function getApiKeyReadAccessFilter( - role: string | undefined -): ReadAccessFilter { - let readAccessFilter: ReadAccessFilter = { - globalReadAccess: false, - projects: [], - }; - - // Eventually, we may support API keys that don't have readAccess for all projects - if (role && (role === "admin" || role === "readonly")) { - readAccessFilter = FULL_ACCESS_PERMISSIONS; - } - - return readAccessFilter; -} - -export function getReadAccessFilter(userPermissions: UserPermissions) { - const readAccess: ReadAccessFilter = { - globalReadAccess: userPermissions.global.permissions.readData || false, - projects: [], - }; - - Object.entries(userPermissions.projects).forEach( - ([project, projectPermissions]) => { - readAccess.projects.push({ - id: project, - readAccess: projectPermissions.permissions.readData || false, - }); - } - ); - - return readAccess; -} -export function hasReadAccess( - filter: ReadAccessFilter, - projects: string | string[] | undefined -): boolean { - // If the resource is available to all projects (an empty array), then everyone should have read access - if (Array.isArray(projects) && !projects?.length) { - return true; - } - - const hasGlobaReadAccess = filter.globalReadAccess; - - // if the user doesn't have project specific roles or resource doesn't have a project (project is an empty string), fallback to user's global role - if (!filter.projects.length || !projects) { - return hasGlobaReadAccess; - } - - const resourceProjects = Array.isArray(projects) ? projects : [projects]; - - // if the user doesn't have global read access, but they do have read access for atleast one of the resource's projects, allow read access to resource - if (!hasGlobaReadAccess) { - return resourceProjects.some((project) => { - return filter.projects.some((p) => p.id === project && p.readAccess); - }); - } - - // otherwise, don't allow read access only if the user's project-specific roles restrict read access for all of the resource's projects - const everyProjectRestrictsReadAccess = resourceProjects.every((project) => { - return filter.projects.some((p) => p.id === project && !p.readAccess); - }); - - return everyProjectRestrictsReadAccess ? false : true; -} diff --git a/packages/shared/src/permissions/permissionsClass.ts b/packages/shared/src/permissions/permissionsClass.ts index 215286301d0..392661a9dd0 100644 --- a/packages/shared/src/permissions/permissionsClass.ts +++ b/packages/shared/src/permissions/permissionsClass.ts @@ -1,6 +1,8 @@ import { FeatureInterface } from "back-end/types/feature"; import { MetricInterface } from "back-end/types/metric"; import { + EnvScopedPermission, + Environment, GlobalPermission, Permission, ProjectScopedPermission, @@ -9,11 +11,14 @@ import { } from "back-end/types/organization"; import { IdeaInterface } from "back-end/types/idea"; import { + FactMetricInterface, FactTableInterface, UpdateFactTableProps, } from "back-end/types/fact-table"; import { ExperimentInterface } from "back-end/types/experiment"; import { DataSourceInterface } from "back-end/types/datasource"; +import { UpdateProps } from "back-end/types/models"; +import { SDKConnectionInterface } from "back-end/types/sdk-connection"; import { READ_ONLY_PERMISSIONS } from "./permissions.utils"; class PermissionError extends Error { constructor(message: string) { @@ -428,6 +433,41 @@ export class Permissions { return this.checkProjectFilterPermission(factTable, "manageFactTables"); }; + public canCreateAndUpdateFactFilter = ( + factTable: Pick + ): boolean => { + return this.checkProjectFilterPermission(factTable, "manageFactTables"); + }; + + public canDeleteFactFilter = ( + factTable: Pick + ): boolean => { + return this.checkProjectFilterPermission(factTable, "manageFactTables"); + }; + + public canCreateFactMetric = ( + metric: Pick + ): boolean => { + return this.checkProjectFilterPermission(metric, "createMetrics"); + }; + + public canUpdateFactMetric = ( + existing: Pick, + updates: UpdateProps + ): boolean => { + return this.checkProjectFilterUpdatePermission( + existing, + updates, + "createMetrics" + ); + }; + + public canDeleteFactMetric = ( + metric: Pick + ): boolean => { + return this.checkProjectFilterPermission(metric, "createMetrics"); + }; + public canCreateMetric = ( metric: Pick ): boolean => { @@ -584,12 +624,129 @@ export class Permissions { return this.checkProjectFilterPermission(datasource, "runQueries"); }; + // ENV_SCOPED_PERMISSIONS + public canPublishFeature = ( + feature: Pick, + environments: string[] + ): boolean => { + return this.checkEnvFilterPermission( + { + projects: feature.project ? [feature.project] : [], + }, + environments, + "publishFeatures" + ); + }; + + public canRunExperiment = ( + experiment: Pick, + environments: string[] + ): boolean => { + return this.checkEnvFilterPermission( + { + projects: experiment.project ? [experiment.project] : [], + }, + environments, + "runExperiments" + ); + }; + + //TODO: Refactor this into two separate methods and eliminate updating envs from organizations.controller.putOrganization - Github Issue #2494 + public canCreateOrUpdateEnvironment = ( + environment: Pick + ): boolean => { + return this.checkEnvFilterPermission( + { + projects: environment.projects || [], + }, + [environment.id], + "manageEnvironments" + ); + }; + + public canDeleteEnvironment = ( + environment: Pick + ): boolean => { + return this.checkEnvFilterPermission( + { + projects: environment.projects || [], + }, + [environment.id], + "manageEnvironments" + ); + }; + + // UI helper - when determining if we can show the `Create SDK Connection` button, this ignores any env level restrictions + // and just takes in the current project + public canViewCreateSDKConnectionModal = (project?: string): boolean => { + return this.hasPermission("manageEnvironments", project || ""); + }; + + public canCreateSDKConnection = ( + sdkConnection: Pick + ): boolean => { + return this.checkEnvFilterPermission( + sdkConnection, + [sdkConnection.environment], + "manageEnvironments" + ); + }; + + public canUpdateSDKConnection = ( + existing: { projects?: string[]; environment?: string }, + updates: { projects?: string[]; environment?: string } + ): boolean => { + return this.checkEnvFilterUpdatePermission( + existing, + updates, + "manageEnvironments" + ); + }; + + public canDeleteSDKConnection = ( + sdkConnection: Pick + ): boolean => { + return this.checkEnvFilterPermission( + sdkConnection, + [sdkConnection.environment], + "manageEnvironments" + ); + }; + public throwPermissionError(): void { throw new PermissionError( "You do not have permission to perform this action" ); } + public canReadSingleProjectResource = ( + project: string | undefined + ): boolean => { + return this.hasPermission("readData", project || ""); + }; + + public canReadMultiProjectResource = ( + projects: string[] | undefined + ): boolean => { + if (this.superAdmin) { + return true; + } + + // If the resource doesn't have a projects property or it's an empty array + // that means it's in all projects + if (!projects || !projects.length) { + const projectsToCheck = [ + "", + ...Object.keys(this.userPermissions.projects), + ]; + // Must have read access globally or in at least 1 project + return projectsToCheck.some((p) => this.hasPermission("readData", p)); + } + + // Otherwise, check if they have read access for atleast 1 of the resource's projects + return projects.some((p) => this.hasPermission("readData", p)); + }; + private checkGlobalPermission(permissionToCheck: GlobalPermission): boolean { if (this.superAdmin) { return true; @@ -639,6 +796,42 @@ export class Permissions { return true; } + public checkEnvFilterPermission( + obj: { projects?: string[] }, + envs: string[], + permission: EnvScopedPermission + ): boolean { + const projects = obj.projects?.length ? obj.projects : [""]; + + return projects.every((project) => + this.hasPermission(permission, project, envs) + ); + } + + private checkEnvFilterUpdatePermission( + existing: { projects?: string[]; environment?: string }, + updates: { projects?: string[]; environment?: string }, + permission: EnvScopedPermission + ): boolean { + if ( + !this.checkEnvFilterPermission( + existing, + existing.environment ? [existing.environment] : [], + permission + ) + ) { + return false; + } + + const updatedObj = { ...existing, ...updates }; + + return this.checkEnvFilterPermission( + updatedObj, + updatedObj.environment ? [updatedObj.environment] : [], + permission + ); + } + private hasPermission( permissionToCheck: Permission, project: string, diff --git a/packages/shared/src/settings/index.ts b/packages/shared/src/settings/index.ts index c51474bd7c1..d13eb5a4797 100644 --- a/packages/shared/src/settings/index.ts +++ b/packages/shared/src/settings/index.ts @@ -85,6 +85,8 @@ export const resolvers: Record< secureAttributeSalt: genDefaultResolver("secureAttributeSalt"), killswitchConfirmation: genDefaultResolver("killswitchConfirmation"), requireReviews: genDefaultResolver("requireReviews"), + featureKeyExample: genDefaultResolver("featureKeyExample"), + featureRegexValidator: genDefaultResolver("featureRegexValidator"), }; const scopeSettings = ( diff --git a/packages/shared/src/settings/resolvers/genDefaultSettings.ts b/packages/shared/src/settings/resolvers/genDefaultSettings.ts index 7be79fcae62..b2f74487df0 100644 --- a/packages/shared/src/settings/resolvers/genDefaultSettings.ts +++ b/packages/shared/src/settings/resolvers/genDefaultSettings.ts @@ -30,7 +30,10 @@ export const DEFAULT_LOSE_RISK = null; export const DEFAULT_WIN_RISK = null; export const DEFAULT_SECURE_ATTRIBUTE_SALT = ""; export const DEFAULT_KILLSWITCH_CONFIRMATION = false; +export const DEFAULT_REQUIRE_REVIEW = false; export const DEFAULT_REUIRE_REVIEW = false; +export const DEFAULT_FEATURE_KEY_EXAMPLE = ""; +export const DEFAULT_FEATURE_REGEX_VALIDATOR = ""; export const DEFAULT_METRIC_DEFAULTS: MetricDefaults = { maxPercentageChange: 0.5, @@ -72,6 +75,8 @@ export default function genDefaultSettings(): Settings { winRisk: DEFAULT_WIN_RISK, secureAttributeSalt: DEFAULT_SECURE_ATTRIBUTE_SALT, killswitchConfirmation: DEFAULT_KILLSWITCH_CONFIRMATION, - requireReviews: DEFAULT_REUIRE_REVIEW, + requireReviews: DEFAULT_REQUIRE_REVIEW, + featureKeyExample: DEFAULT_FEATURE_KEY_EXAMPLE, + featureRegexValidator: DEFAULT_FEATURE_REGEX_VALIDATOR, }; } diff --git a/packages/shared/src/settings/types.ts b/packages/shared/src/settings/types.ts index e63ebb17c9b..53c7281f716 100644 --- a/packages/shared/src/settings/types.ts +++ b/packages/shared/src/settings/types.ts @@ -47,9 +47,7 @@ export interface ScopeDefinition { report?: ReportInterface; } -export type ScopeSettingsFn = ( - scopes: ScopeDefinition -) => { +export type ScopeSettingsFn = (scopes: ScopeDefinition) => { settings: ScopedSettings; scopeSettings: ScopeSettingsFn; }; @@ -82,6 +80,8 @@ interface BaseSettings { secureAttributeSalt: string; killswitchConfirmation: boolean; requireReviews: boolean | RequireReview[]; + featureKeyExample: string; + featureRegexValidator: string; } // todo: encapsulate all settings, including experiment diff --git a/packages/shared/test/permissions.test.ts b/packages/shared/test/permissions.test.ts new file mode 100644 index 00000000000..e4f0abdbfc6 --- /dev/null +++ b/packages/shared/test/permissions.test.ts @@ -0,0 +1,897 @@ +import { roleToPermissionMap } from "back-end/src/util/organization.util"; +import { MemberRole, OrganizationInterface } from "back-end/types/organization"; +import { Permissions } from "../permissions"; + +describe("Role permissions", () => { + const testOrg: OrganizationInterface = { + id: "org_sktwi1id9l7z9xkjb", + name: "Test Org", + ownerEmail: "test@test.com", + url: "https://test.com", + dateCreated: new Date(), + invites: [], + members: [], + settings: { + environments: [{ id: "production" }], + }, + }; + + function getPermissions(role: MemberRole) { + return new Permissions( + { + global: { + permissions: roleToPermissionMap(role, testOrg), + limitAccessByEnvironment: false, + environments: [], + }, + projects: {}, + }, + false + ); + } + + const project = ""; + const projects = []; + const projectResource = { project: "" }; + const projectsResource = { projects: [] }; + const environmentsResource = { projects: [], id: "" }; + const envs = ["production"]; + const environment = ""; + const updates = {}; + + it("has correct permissions for noaccess", () => { + const p = getPermissions("noaccess"); + expect(p.canAddComment(projects)).toBe(false); + expect(p.canBypassApprovalChecks(projectResource)).toBe(false); + expect(p.canCreateAndUpdateTag()).toBe(false); + expect(p.canCreateApiKey()).toBe(false); + expect(p.canCreateArchetype()).toBe(false); + expect(p.canCreateAttribute(projectsResource)).toBe(false); + expect(p.canCreateDataSource(projectsResource)).toBe(false); + expect(p.canCreateDimension()).toBe(false); + expect(p.canCreateEventWebhook()).toBe(false); + expect(p.canCreateExperiment(projectResource)).toBe(false); + expect(p.canCreateFactMetric(projectsResource)).toBe(false); + expect(p.canCreateFactTable(projectsResource)).toBe(false); + expect(p.canCreateFeature(projectResource)).toBe(false); + expect(p.canCreateIdea(projectResource)).toBe(false); + expect(p.canCreateMetric(projectsResource)).toBe(false); + expect(p.canCreateNamespace()).toBe(false); + expect(p.canCreatePresentation()).toBe(false); + expect(p.canCreateProjects()).toBe(false); + expect(p.canCreateReport(projectResource)).toBe(false); + expect(p.canCreateSDKWebhook()).toBe(false); + expect(p.canCreateSavedGroup()).toBe(false); + expect(p.canCreateSegment()).toBe(false); + expect(p.canCreateVisualChange(projectResource)).toBe(false); + expect(p.canDeleteApiKey()).toBe(false); + expect(p.canDeleteArchetype()).toBe(false); + expect(p.canDeleteAttribute(projectsResource)).toBe(false); + expect(p.canDeleteDataSource(projectsResource)).toBe(false); + expect(p.canDeleteDimension()).toBe(false); + expect(p.canDeleteEventWebhook()).toBe(false); + expect(p.canDeleteExperiment(projectResource)).toBe(false); + expect(p.canDeleteFactMetric(projectsResource)).toBe(false); + expect(p.canDeleteFactTable(projectsResource)).toBe(false); + expect(p.canDeleteFeature(projectResource)).toBe(false); + expect(p.canDeleteIdea(projectResource)).toBe(false); + expect(p.canDeleteMetric(projectsResource)).toBe(false); + expect(p.canDeleteNamespace()).toBe(false); + expect(p.canDeletePresentation()).toBe(false); + expect(p.canDeleteProject(project)).toBe(false); + expect(p.canDeleteReport(projectResource)).toBe(false); + expect(p.canDeleteSDKWebhook()).toBe(false); + expect(p.canDeleteSavedGroup()).toBe(false); + expect(p.canDeleteSegment()).toBe(false); + expect(p.canDeleteTag()).toBe(false); + expect(p.canManageBilling()).toBe(false); + expect(p.canManageFeatureDrafts(projectResource)).toBe(false); + expect(p.canManageIntegrations()).toBe(false); + expect(p.canManageNorthStarMetric()).toBe(false); + expect(p.canManageOrgSettings()).toBe(false); + expect(p.canManageTeam()).toBe(false); + expect(p.canPublishFeature(projectResource, envs)).toBe(false); + expect(p.canReviewFeatureDrafts(projectResource)).toBe(false); + expect(p.canRunExperiment(projectResource, envs)).toBe(false); + expect(p.canRunExperimentQueries(projectsResource)).toBe(false); + expect(p.canRunFactQueries(projectsResource)).toBe(false); + expect(p.canRunHealthQueries(projectsResource)).toBe(false); + expect(p.canRunMetricQueries(projectsResource)).toBe(false); + expect(p.canRunPastExperimentQueries(projectsResource)).toBe(false); + expect(p.canRunSchemaQueries(projectsResource)).toBe(false); + expect(p.canRunTestQueries(projectsResource)).toBe(false); + expect(p.canSuperDeleteReport()).toBe(false); + expect(p.canUpdateArchetype()).toBe(false); + expect(p.canUpdateAttribute(projectsResource, updates)).toBe(false); + expect(p.canUpdateDataSourceParams(projectsResource)).toBe(false); + expect(p.canUpdateDataSourceSettings(projectsResource)).toBe(false); + expect(p.canUpdateDimension()).toBe(false); + expect(p.canUpdateEventWebhook()).toBe(false); + expect(p.canUpdateExperiment(projectResource, updates)).toBe(false); + expect(p.canUpdateFactMetric(projectsResource, updates)).toBe(false); + expect(p.canUpdateFactTable(projectsResource, updates)).toBe(false); + expect(p.canUpdateFeature(projectResource, updates)).toBe(false); + expect(p.canUpdateIdea(projectResource, updates)).toBe(false); + expect(p.canUpdateMetric(projectsResource, updates)).toBe(false); + expect(p.canUpdateNamespace()).toBe(false); + expect(p.canUpdatePresentation()).toBe(false); + expect(p.canUpdateProject(project)).toBe(false); + expect(p.canUpdateReport(projectResource)).toBe(false); + expect(p.canUpdateSDKWebhook()).toBe(false); + expect(p.canUpdateSavedGroup()).toBe(false); + expect(p.canUpdateSegment()).toBe(false); + expect(p.canUpdateSomeProjects()).toBe(false); + expect(p.canUpdateVisualChange(projectResource)).toBe(false); + expect(p.canViewAttributeModal()).toBe(false); + expect(p.canViewCreateDataSourceModal()).toBe(false); + expect(p.canViewCreateFactTableModal()).toBe(false); + expect(p.canViewEditFactTableModal(projectsResource)).toBe(false); + expect(p.canViewEventWebhook()).toBe(false); + expect(p.canViewEvents()).toBe(false); + expect(p.canViewExperimentModal()).toBe(false); + expect(p.canViewFeatureModal()).toBe(false); + expect(p.canViewIdeaModal()).toBe(false); + expect(p.canViewReportModal()).toBe(false); + expect(p.canCreateAndUpdateFactFilter(projectsResource)).toBe(false); + expect(p.canDeleteFactFilter(projectsResource)).toBe(false); + expect(p.canCreateOrUpdateEnvironment(environmentsResource)).toBe(false); + expect(p.canDeleteEnvironment(environmentsResource)).toBe(false); + expect(p.canViewCreateSDKConnectionModal(project)).toBe(false); + expect(p.canCreateSDKConnection({ projects, environment })).toBe(false); + expect(p.canUpdateSDKConnection({ projects, environment }, updates)).toBe( + false + ); + expect(p.canDeleteSDKConnection({ projects, environment })).toBe(false); + expect(p.canReadSingleProjectResource(project)).toBe(false); + expect(p.canReadMultiProjectResource(projects)).toBe(false); + }); + + it("has correct permissions for readonly", () => { + const p = getPermissions("readonly"); + expect(p.canAddComment(projects)).toBe(false); + expect(p.canBypassApprovalChecks(projectResource)).toBe(false); + expect(p.canCreateAndUpdateTag()).toBe(false); + expect(p.canCreateApiKey()).toBe(false); + expect(p.canCreateArchetype()).toBe(false); + expect(p.canCreateAttribute(projectsResource)).toBe(false); + expect(p.canCreateDataSource(projectsResource)).toBe(false); + expect(p.canCreateDimension()).toBe(false); + expect(p.canCreateEventWebhook()).toBe(false); + expect(p.canCreateExperiment(projectResource)).toBe(false); + expect(p.canCreateFactMetric(projectsResource)).toBe(false); + expect(p.canCreateFactTable(projectsResource)).toBe(false); + expect(p.canCreateFeature(projectResource)).toBe(false); + expect(p.canCreateIdea(projectResource)).toBe(false); + expect(p.canCreateMetric(projectsResource)).toBe(false); + expect(p.canCreateNamespace()).toBe(false); + expect(p.canCreatePresentation()).toBe(false); + expect(p.canCreateProjects()).toBe(false); + expect(p.canCreateReport(projectResource)).toBe(false); + expect(p.canCreateSDKWebhook()).toBe(false); + expect(p.canCreateSavedGroup()).toBe(false); + expect(p.canCreateSegment()).toBe(false); + expect(p.canCreateVisualChange(projectResource)).toBe(false); + expect(p.canDeleteApiKey()).toBe(false); + expect(p.canDeleteArchetype()).toBe(false); + expect(p.canDeleteAttribute(projectsResource)).toBe(false); + expect(p.canDeleteDataSource(projectsResource)).toBe(false); + expect(p.canDeleteDimension()).toBe(false); + expect(p.canDeleteEventWebhook()).toBe(false); + expect(p.canDeleteExperiment(projectResource)).toBe(false); + expect(p.canDeleteFactMetric(projectsResource)).toBe(false); + expect(p.canDeleteFactTable(projectsResource)).toBe(false); + expect(p.canDeleteFeature(projectResource)).toBe(false); + expect(p.canDeleteIdea(projectResource)).toBe(false); + expect(p.canDeleteMetric(projectsResource)).toBe(false); + expect(p.canDeleteNamespace()).toBe(false); + expect(p.canDeletePresentation()).toBe(false); + expect(p.canDeleteProject(project)).toBe(false); + expect(p.canDeleteReport(projectResource)).toBe(false); + expect(p.canDeleteSDKWebhook()).toBe(false); + expect(p.canDeleteSavedGroup()).toBe(false); + expect(p.canDeleteSegment()).toBe(false); + expect(p.canDeleteTag()).toBe(false); + expect(p.canManageBilling()).toBe(false); + expect(p.canManageFeatureDrafts(projectResource)).toBe(false); + expect(p.canManageIntegrations()).toBe(false); + expect(p.canManageNorthStarMetric()).toBe(false); + expect(p.canManageOrgSettings()).toBe(false); + expect(p.canManageTeam()).toBe(false); + expect(p.canPublishFeature(projectResource, envs)).toBe(false); + expect(p.canReviewFeatureDrafts(projectResource)).toBe(false); + expect(p.canRunExperiment(projectResource, envs)).toBe(false); + expect(p.canRunExperimentQueries(projectsResource)).toBe(false); + expect(p.canRunFactQueries(projectsResource)).toBe(false); + expect(p.canRunHealthQueries(projectsResource)).toBe(false); + expect(p.canRunMetricQueries(projectsResource)).toBe(false); + expect(p.canRunPastExperimentQueries(projectsResource)).toBe(false); + expect(p.canRunSchemaQueries(projectsResource)).toBe(false); + expect(p.canRunTestQueries(projectsResource)).toBe(false); + expect(p.canSuperDeleteReport()).toBe(false); + expect(p.canUpdateArchetype()).toBe(false); + expect(p.canUpdateAttribute(projectsResource, updates)).toBe(false); + expect(p.canUpdateDataSourceParams(projectsResource)).toBe(false); + expect(p.canUpdateDataSourceSettings(projectsResource)).toBe(false); + expect(p.canUpdateDimension()).toBe(false); + expect(p.canUpdateEventWebhook()).toBe(false); + expect(p.canUpdateExperiment(projectResource, updates)).toBe(false); + expect(p.canUpdateFactMetric(projectsResource, updates)).toBe(false); + expect(p.canUpdateFactTable(projectsResource, updates)).toBe(false); + expect(p.canUpdateFeature(projectResource, updates)).toBe(false); + expect(p.canUpdateIdea(projectResource, updates)).toBe(false); + expect(p.canUpdateMetric(projectsResource, updates)).toBe(false); + expect(p.canUpdateNamespace()).toBe(false); + expect(p.canUpdatePresentation()).toBe(false); + expect(p.canUpdateProject(project)).toBe(false); + expect(p.canUpdateReport(projectResource)).toBe(false); + expect(p.canUpdateSDKWebhook()).toBe(false); + expect(p.canUpdateSavedGroup()).toBe(false); + expect(p.canUpdateSegment()).toBe(false); + expect(p.canUpdateSomeProjects()).toBe(false); + expect(p.canUpdateVisualChange(projectResource)).toBe(false); + expect(p.canViewAttributeModal()).toBe(false); + expect(p.canViewCreateDataSourceModal()).toBe(false); + expect(p.canViewCreateFactTableModal()).toBe(false); + expect(p.canViewEditFactTableModal(projectsResource)).toBe(false); + expect(p.canViewEventWebhook()).toBe(false); + expect(p.canViewEvents()).toBe(false); + expect(p.canViewExperimentModal()).toBe(false); + expect(p.canViewFeatureModal()).toBe(false); + expect(p.canViewIdeaModal()).toBe(false); + expect(p.canViewReportModal()).toBe(false); + expect(p.canCreateAndUpdateFactFilter(projectsResource)).toBe(false); + expect(p.canDeleteFactFilter(projectsResource)).toBe(false); + expect(p.canCreateOrUpdateEnvironment(environmentsResource)).toBe(false); + expect(p.canDeleteEnvironment(environmentsResource)).toBe(false); + expect(p.canViewCreateSDKConnectionModal(project)).toBe(false); + expect(p.canCreateSDKConnection({ projects, environment })).toBe(false); + expect(p.canUpdateSDKConnection({ projects, environment }, updates)).toBe( + false + ); + expect(p.canDeleteSDKConnection({ projects, environment })).toBe(false); + expect(p.canReadSingleProjectResource(project)).toBe(true); + expect(p.canReadMultiProjectResource(projects)).toBe(true); + }); + + it("has correct permissions for visualEditor", () => { + const p = getPermissions("visualEditor"); + expect(p.canAddComment(projects)).toBe(false); + expect(p.canBypassApprovalChecks(projectResource)).toBe(false); + expect(p.canCreateAndUpdateTag()).toBe(false); + expect(p.canCreateApiKey()).toBe(false); + expect(p.canCreateArchetype()).toBe(false); + expect(p.canCreateAttribute(projectsResource)).toBe(false); + expect(p.canCreateDataSource(projectsResource)).toBe(false); + expect(p.canCreateDimension()).toBe(false); + expect(p.canCreateEventWebhook()).toBe(false); + expect(p.canCreateExperiment(projectResource)).toBe(false); + expect(p.canCreateFactMetric(projectsResource)).toBe(false); + expect(p.canCreateFactTable(projectsResource)).toBe(false); + expect(p.canCreateFeature(projectResource)).toBe(false); + expect(p.canCreateIdea(projectResource)).toBe(false); + expect(p.canCreateMetric(projectsResource)).toBe(false); + expect(p.canCreateNamespace()).toBe(false); + expect(p.canCreatePresentation()).toBe(false); + expect(p.canCreateProjects()).toBe(false); + expect(p.canCreateReport(projectResource)).toBe(false); + expect(p.canCreateSDKWebhook()).toBe(false); + expect(p.canCreateSavedGroup()).toBe(false); + expect(p.canCreateSegment()).toBe(false); + expect(p.canCreateVisualChange(projectResource)).toBe(true); + expect(p.canDeleteApiKey()).toBe(false); + expect(p.canDeleteArchetype()).toBe(false); + expect(p.canDeleteAttribute(projectsResource)).toBe(false); + expect(p.canDeleteDataSource(projectsResource)).toBe(false); + expect(p.canDeleteDimension()).toBe(false); + expect(p.canDeleteEventWebhook()).toBe(false); + expect(p.canDeleteExperiment(projectResource)).toBe(false); + expect(p.canDeleteFactMetric(projectsResource)).toBe(false); + expect(p.canDeleteFactTable(projectsResource)).toBe(false); + expect(p.canDeleteFeature(projectResource)).toBe(false); + expect(p.canDeleteIdea(projectResource)).toBe(false); + expect(p.canDeleteMetric(projectsResource)).toBe(false); + expect(p.canDeleteNamespace()).toBe(false); + expect(p.canDeletePresentation()).toBe(false); + expect(p.canDeleteProject(project)).toBe(false); + expect(p.canDeleteReport(projectResource)).toBe(false); + expect(p.canDeleteSDKWebhook()).toBe(false); + expect(p.canDeleteSavedGroup()).toBe(false); + expect(p.canDeleteSegment()).toBe(false); + expect(p.canDeleteTag()).toBe(false); + expect(p.canManageBilling()).toBe(false); + expect(p.canManageFeatureDrafts(projectResource)).toBe(false); + expect(p.canManageIntegrations()).toBe(false); + expect(p.canManageNorthStarMetric()).toBe(false); + expect(p.canManageOrgSettings()).toBe(false); + expect(p.canManageTeam()).toBe(false); + expect(p.canPublishFeature(projectResource, envs)).toBe(false); + expect(p.canReviewFeatureDrafts(projectResource)).toBe(false); + expect(p.canRunExperiment(projectResource, envs)).toBe(false); + expect(p.canRunExperimentQueries(projectsResource)).toBe(false); + expect(p.canRunFactQueries(projectsResource)).toBe(false); + expect(p.canRunHealthQueries(projectsResource)).toBe(false); + expect(p.canRunMetricQueries(projectsResource)).toBe(false); + expect(p.canRunPastExperimentQueries(projectsResource)).toBe(false); + expect(p.canRunSchemaQueries(projectsResource)).toBe(false); + expect(p.canRunTestQueries(projectsResource)).toBe(false); + expect(p.canSuperDeleteReport()).toBe(false); + expect(p.canUpdateArchetype()).toBe(false); + expect(p.canUpdateAttribute(projectsResource, updates)).toBe(false); + expect(p.canUpdateDataSourceParams(projectsResource)).toBe(false); + expect(p.canUpdateDataSourceSettings(projectsResource)).toBe(false); + expect(p.canUpdateDimension()).toBe(false); + expect(p.canUpdateEventWebhook()).toBe(false); + expect(p.canUpdateExperiment(projectResource, updates)).toBe(false); + expect(p.canUpdateFactMetric(projectsResource, updates)).toBe(false); + expect(p.canUpdateFactTable(projectsResource, updates)).toBe(false); + expect(p.canUpdateFeature(projectResource, updates)).toBe(false); + expect(p.canUpdateIdea(projectResource, updates)).toBe(false); + expect(p.canUpdateMetric(projectsResource, updates)).toBe(false); + expect(p.canUpdateNamespace()).toBe(false); + expect(p.canUpdatePresentation()).toBe(false); + expect(p.canUpdateProject(project)).toBe(false); + expect(p.canUpdateReport(projectResource)).toBe(false); + expect(p.canUpdateSDKWebhook()).toBe(false); + expect(p.canUpdateSavedGroup()).toBe(false); + expect(p.canUpdateSegment()).toBe(false); + expect(p.canUpdateSomeProjects()).toBe(false); + expect(p.canUpdateVisualChange(projectResource)).toBe(true); + expect(p.canViewAttributeModal()).toBe(false); + expect(p.canViewCreateDataSourceModal()).toBe(false); + expect(p.canViewCreateFactTableModal()).toBe(false); + expect(p.canViewEditFactTableModal(projectsResource)).toBe(false); + expect(p.canViewEventWebhook()).toBe(false); + expect(p.canViewEvents()).toBe(false); + expect(p.canViewExperimentModal()).toBe(false); + expect(p.canViewFeatureModal()).toBe(false); + expect(p.canViewIdeaModal()).toBe(false); + expect(p.canViewReportModal()).toBe(false); + expect(p.canCreateAndUpdateFactFilter(projectsResource)).toBe(false); + expect(p.canDeleteFactFilter(projectsResource)).toBe(false); + expect(p.canCreateOrUpdateEnvironment(environmentsResource)).toBe(false); + expect(p.canDeleteEnvironment(environmentsResource)).toBe(false); + expect(p.canViewCreateSDKConnectionModal(project)).toBe(false); + expect(p.canCreateSDKConnection({ projects, environment })).toBe(false); + expect(p.canUpdateSDKConnection({ projects, environment }, updates)).toBe( + false + ); + expect(p.canDeleteSDKConnection({ projects, environment })).toBe(false); + expect(p.canReadSingleProjectResource(project)).toBe(true); + expect(p.canReadMultiProjectResource(projects)).toBe(true); + }); + + it("has correct permissions for collaborator", () => { + const p = getPermissions("collaborator"); + expect(p.canAddComment(projects)).toBe(true); + expect(p.canBypassApprovalChecks(projectResource)).toBe(false); + expect(p.canCreateAndUpdateTag()).toBe(false); + expect(p.canCreateApiKey()).toBe(false); + expect(p.canCreateArchetype()).toBe(false); + expect(p.canCreateAttribute(projectsResource)).toBe(false); + expect(p.canCreateDataSource(projectsResource)).toBe(false); + expect(p.canCreateDimension()).toBe(false); + expect(p.canCreateEventWebhook()).toBe(false); + expect(p.canCreateExperiment(projectResource)).toBe(false); + expect(p.canCreateFactMetric(projectsResource)).toBe(false); + expect(p.canCreateFactTable(projectsResource)).toBe(false); + expect(p.canCreateFeature(projectResource)).toBe(false); + expect(p.canCreateIdea(projectResource)).toBe(true); + expect(p.canCreateMetric(projectsResource)).toBe(false); + expect(p.canCreateNamespace()).toBe(false); + expect(p.canCreatePresentation()).toBe(true); + expect(p.canCreateProjects()).toBe(false); + expect(p.canCreateReport(projectResource)).toBe(false); + expect(p.canCreateSDKWebhook()).toBe(false); + expect(p.canCreateSavedGroup()).toBe(false); + expect(p.canCreateSegment()).toBe(false); + expect(p.canCreateVisualChange(projectResource)).toBe(false); + expect(p.canDeleteApiKey()).toBe(false); + expect(p.canDeleteArchetype()).toBe(false); + expect(p.canDeleteAttribute(projectsResource)).toBe(false); + expect(p.canDeleteDataSource(projectsResource)).toBe(false); + expect(p.canDeleteDimension()).toBe(false); + expect(p.canDeleteEventWebhook()).toBe(false); + expect(p.canDeleteExperiment(projectResource)).toBe(false); + expect(p.canDeleteFactMetric(projectsResource)).toBe(false); + expect(p.canDeleteFactTable(projectsResource)).toBe(false); + expect(p.canDeleteFeature(projectResource)).toBe(false); + expect(p.canDeleteIdea(projectResource)).toBe(true); + expect(p.canDeleteMetric(projectsResource)).toBe(false); + expect(p.canDeleteNamespace()).toBe(false); + expect(p.canDeletePresentation()).toBe(true); + expect(p.canDeleteProject(project)).toBe(false); + expect(p.canDeleteReport(projectResource)).toBe(false); + expect(p.canDeleteSDKWebhook()).toBe(false); + expect(p.canDeleteSavedGroup()).toBe(false); + expect(p.canDeleteSegment()).toBe(false); + expect(p.canDeleteTag()).toBe(false); + expect(p.canManageBilling()).toBe(false); + expect(p.canManageFeatureDrafts(projectResource)).toBe(false); + expect(p.canManageIntegrations()).toBe(false); + expect(p.canManageNorthStarMetric()).toBe(false); + expect(p.canManageOrgSettings()).toBe(false); + expect(p.canManageTeam()).toBe(false); + expect(p.canPublishFeature(projectResource, envs)).toBe(false); + expect(p.canReviewFeatureDrafts(projectResource)).toBe(false); + expect(p.canRunExperiment(projectResource, envs)).toBe(false); + expect(p.canRunExperimentQueries(projectsResource)).toBe(false); + expect(p.canRunFactQueries(projectsResource)).toBe(false); + expect(p.canRunHealthQueries(projectsResource)).toBe(false); + expect(p.canRunMetricQueries(projectsResource)).toBe(false); + expect(p.canRunPastExperimentQueries(projectsResource)).toBe(false); + expect(p.canRunSchemaQueries(projectsResource)).toBe(false); + expect(p.canRunTestQueries(projectsResource)).toBe(false); + expect(p.canSuperDeleteReport()).toBe(false); + expect(p.canUpdateArchetype()).toBe(false); + expect(p.canUpdateAttribute(projectsResource, updates)).toBe(false); + expect(p.canUpdateDataSourceParams(projectsResource)).toBe(false); + expect(p.canUpdateDataSourceSettings(projectsResource)).toBe(false); + expect(p.canUpdateDimension()).toBe(false); + expect(p.canUpdateEventWebhook()).toBe(false); + expect(p.canUpdateExperiment(projectResource, updates)).toBe(false); + expect(p.canUpdateFactMetric(projectsResource, updates)).toBe(false); + expect(p.canUpdateFactTable(projectsResource, updates)).toBe(false); + expect(p.canUpdateFeature(projectResource, updates)).toBe(false); + expect(p.canUpdateIdea(projectResource, updates)).toBe(true); + expect(p.canUpdateMetric(projectsResource, updates)).toBe(false); + expect(p.canUpdateNamespace()).toBe(false); + expect(p.canUpdatePresentation()).toBe(true); + expect(p.canUpdateProject(project)).toBe(false); + expect(p.canUpdateReport(projectResource)).toBe(false); + expect(p.canUpdateSDKWebhook()).toBe(false); + expect(p.canUpdateSavedGroup()).toBe(false); + expect(p.canUpdateSegment()).toBe(false); + expect(p.canUpdateSomeProjects()).toBe(false); + expect(p.canUpdateVisualChange(projectResource)).toBe(false); + expect(p.canViewAttributeModal()).toBe(false); + expect(p.canViewCreateDataSourceModal()).toBe(false); + expect(p.canViewCreateFactTableModal()).toBe(false); + expect(p.canViewEditFactTableModal(projectsResource)).toBe(false); + expect(p.canViewEventWebhook()).toBe(false); + expect(p.canViewEvents()).toBe(false); + expect(p.canViewExperimentModal()).toBe(false); + expect(p.canViewFeatureModal()).toBe(false); + expect(p.canViewIdeaModal()).toBe(true); + expect(p.canViewReportModal()).toBe(false); + expect(p.canCreateAndUpdateFactFilter(projectsResource)).toBe(false); + expect(p.canDeleteFactFilter(projectsResource)).toBe(false); + expect(p.canCreateOrUpdateEnvironment(environmentsResource)).toBe(false); + expect(p.canDeleteEnvironment(environmentsResource)).toBe(false); + expect(p.canViewCreateSDKConnectionModal(project)).toBe(false); + expect(p.canCreateSDKConnection({ projects, environment })).toBe(false); + expect(p.canUpdateSDKConnection({ projects, environment }, updates)).toBe( + false + ); + expect(p.canDeleteSDKConnection({ projects, environment })).toBe(false); + expect(p.canReadSingleProjectResource(project)).toBe(true); + expect(p.canReadMultiProjectResource(projects)).toBe(true); + }); + + it("has correct permissions for engineer", () => { + const p = getPermissions("engineer"); + expect(p.canAddComment(projects)).toBe(true); + expect(p.canBypassApprovalChecks(projectResource)).toBe(false); + expect(p.canCreateAndUpdateTag()).toBe(true); + expect(p.canCreateApiKey()).toBe(false); + expect(p.canCreateArchetype()).toBe(true); + expect(p.canCreateAttribute(projectsResource)).toBe(true); + expect(p.canCreateDataSource(projectsResource)).toBe(false); + expect(p.canCreateDimension()).toBe(false); + expect(p.canCreateEventWebhook()).toBe(false); + expect(p.canCreateExperiment(projectResource)).toBe(false); + expect(p.canCreateFactMetric(projectsResource)).toBe(false); + expect(p.canCreateFactTable(projectsResource)).toBe(false); + expect(p.canCreateFeature(projectResource)).toBe(true); + expect(p.canCreateIdea(projectResource)).toBe(true); + expect(p.canCreateMetric(projectsResource)).toBe(false); + expect(p.canCreateNamespace()).toBe(true); + expect(p.canCreatePresentation()).toBe(true); + expect(p.canCreateProjects()).toBe(false); + expect(p.canCreateReport(projectResource)).toBe(false); + expect(p.canCreateSDKWebhook()).toBe(false); + expect(p.canCreateSavedGroup()).toBe(true); + expect(p.canCreateSegment()).toBe(false); + expect(p.canCreateVisualChange(projectResource)).toBe(true); + expect(p.canDeleteApiKey()).toBe(false); + expect(p.canDeleteArchetype()).toBe(true); + expect(p.canDeleteAttribute(projectsResource)).toBe(true); + expect(p.canDeleteDataSource(projectsResource)).toBe(false); + expect(p.canDeleteDimension()).toBe(false); + expect(p.canDeleteEventWebhook()).toBe(false); + expect(p.canDeleteExperiment(projectResource)).toBe(false); + expect(p.canDeleteFactMetric(projectsResource)).toBe(false); + expect(p.canDeleteFactTable(projectsResource)).toBe(false); + expect(p.canDeleteFeature(projectResource)).toBe(true); + expect(p.canDeleteIdea(projectResource)).toBe(true); + expect(p.canDeleteMetric(projectsResource)).toBe(false); + expect(p.canDeleteNamespace()).toBe(true); + expect(p.canDeletePresentation()).toBe(true); + expect(p.canDeleteProject(project)).toBe(false); + expect(p.canDeleteReport(projectResource)).toBe(false); + expect(p.canDeleteSDKWebhook()).toBe(false); + expect(p.canDeleteSavedGroup()).toBe(true); + expect(p.canDeleteSegment()).toBe(false); + expect(p.canDeleteTag()).toBe(true); + expect(p.canManageBilling()).toBe(false); + expect(p.canManageFeatureDrafts(projectResource)).toBe(true); + expect(p.canManageIntegrations()).toBe(false); + expect(p.canManageNorthStarMetric()).toBe(false); + expect(p.canManageOrgSettings()).toBe(false); + expect(p.canManageTeam()).toBe(false); + expect(p.canPublishFeature(projectResource, envs)).toBe(true); + expect(p.canReviewFeatureDrafts(projectResource)).toBe(true); + expect(p.canRunExperiment(projectResource, envs)).toBe(true); + expect(p.canRunExperimentQueries(projectsResource)).toBe(false); + expect(p.canRunFactQueries(projectsResource)).toBe(false); + expect(p.canRunHealthQueries(projectsResource)).toBe(false); + expect(p.canRunMetricQueries(projectsResource)).toBe(false); + expect(p.canRunPastExperimentQueries(projectsResource)).toBe(false); + expect(p.canRunSchemaQueries(projectsResource)).toBe(false); + expect(p.canRunTestQueries(projectsResource)).toBe(false); + expect(p.canSuperDeleteReport()).toBe(false); + expect(p.canUpdateArchetype()).toBe(true); + expect(p.canUpdateAttribute(projectsResource, updates)).toBe(true); + expect(p.canUpdateDataSourceParams(projectsResource)).toBe(false); + expect(p.canUpdateDataSourceSettings(projectsResource)).toBe(false); + expect(p.canUpdateDimension()).toBe(false); + expect(p.canUpdateEventWebhook()).toBe(false); + expect(p.canUpdateExperiment(projectResource, updates)).toBe(false); + expect(p.canUpdateFactMetric(projectsResource, updates)).toBe(false); + expect(p.canUpdateFactTable(projectsResource, updates)).toBe(false); + expect(p.canUpdateFeature(projectResource, updates)).toBe(true); + expect(p.canUpdateIdea(projectResource, updates)).toBe(true); + expect(p.canUpdateMetric(projectsResource, updates)).toBe(false); + expect(p.canUpdateNamespace()).toBe(true); + expect(p.canUpdatePresentation()).toBe(true); + expect(p.canUpdateProject(project)).toBe(false); + expect(p.canUpdateReport(projectResource)).toBe(false); + expect(p.canUpdateSDKWebhook()).toBe(false); + expect(p.canUpdateSavedGroup()).toBe(true); + expect(p.canUpdateSegment()).toBe(false); + expect(p.canUpdateSomeProjects()).toBe(false); + expect(p.canUpdateVisualChange(projectResource)).toBe(true); + expect(p.canViewAttributeModal()).toBe(true); + expect(p.canViewCreateDataSourceModal()).toBe(false); + expect(p.canViewCreateFactTableModal()).toBe(false); + expect(p.canViewEditFactTableModal(projectsResource)).toBe(false); + expect(p.canViewEventWebhook()).toBe(false); + expect(p.canViewEvents()).toBe(false); + expect(p.canViewExperimentModal()).toBe(false); + expect(p.canViewFeatureModal()).toBe(true); + expect(p.canViewIdeaModal()).toBe(true); + expect(p.canViewReportModal()).toBe(false); + expect(p.canCreateAndUpdateFactFilter(projectsResource)).toBe(false); + expect(p.canDeleteFactFilter(projectsResource)).toBe(false); + expect(p.canCreateOrUpdateEnvironment(environmentsResource)).toBe(true); + expect(p.canDeleteEnvironment(environmentsResource)).toBe(true); + expect(p.canViewCreateSDKConnectionModal(project)).toBe(true); + expect(p.canCreateSDKConnection({ projects, environment })).toBe(true); + expect(p.canUpdateSDKConnection({ projects, environment }, updates)).toBe( + true + ); + expect(p.canDeleteSDKConnection({ projects, environment })).toBe(true); + expect(p.canReadSingleProjectResource(project)).toBe(true); + expect(p.canReadMultiProjectResource(projects)).toBe(true); + }); + + it("has correct permissions for analyst", () => { + const p = getPermissions("analyst"); + expect(p.canAddComment(projects)).toBe(true); + expect(p.canBypassApprovalChecks(projectResource)).toBe(false); + expect(p.canCreateAndUpdateTag()).toBe(true); + expect(p.canCreateApiKey()).toBe(false); + expect(p.canCreateArchetype()).toBe(false); + expect(p.canCreateAttribute(projectsResource)).toBe(false); + expect(p.canCreateDataSource(projectsResource)).toBe(false); + expect(p.canCreateDimension()).toBe(true); + expect(p.canCreateEventWebhook()).toBe(false); + expect(p.canCreateExperiment(projectResource)).toBe(true); + expect(p.canCreateFactMetric(projectsResource)).toBe(true); + expect(p.canCreateFactTable(projectsResource)).toBe(true); + expect(p.canCreateFeature(projectResource)).toBe(false); + expect(p.canCreateIdea(projectResource)).toBe(true); + expect(p.canCreateMetric(projectsResource)).toBe(true); + expect(p.canCreateNamespace()).toBe(false); + expect(p.canCreatePresentation()).toBe(true); + expect(p.canCreateProjects()).toBe(false); + expect(p.canCreateReport(projectResource)).toBe(true); + expect(p.canCreateSDKWebhook()).toBe(false); + expect(p.canCreateSavedGroup()).toBe(false); + expect(p.canCreateSegment()).toBe(true); + expect(p.canCreateVisualChange(projectResource)).toBe(true); + expect(p.canDeleteApiKey()).toBe(false); + expect(p.canDeleteArchetype()).toBe(false); + expect(p.canDeleteAttribute(projectsResource)).toBe(false); + expect(p.canDeleteDataSource(projectsResource)).toBe(false); + expect(p.canDeleteDimension()).toBe(true); + expect(p.canDeleteEventWebhook()).toBe(false); + expect(p.canDeleteExperiment(projectResource)).toBe(true); + expect(p.canDeleteFactMetric(projectsResource)).toBe(true); + expect(p.canDeleteFactTable(projectsResource)).toBe(true); + expect(p.canDeleteFeature(projectResource)).toBe(false); + expect(p.canDeleteIdea(projectResource)).toBe(true); + expect(p.canDeleteMetric(projectsResource)).toBe(true); + expect(p.canDeleteNamespace()).toBe(false); + expect(p.canDeletePresentation()).toBe(true); + expect(p.canDeleteProject(project)).toBe(false); + expect(p.canDeleteReport(projectResource)).toBe(true); + expect(p.canDeleteSDKWebhook()).toBe(false); + expect(p.canDeleteSavedGroup()).toBe(false); + expect(p.canDeleteSegment()).toBe(true); + expect(p.canDeleteTag()).toBe(true); + expect(p.canManageBilling()).toBe(false); + expect(p.canManageFeatureDrafts(projectResource)).toBe(false); + expect(p.canManageIntegrations()).toBe(false); + expect(p.canManageNorthStarMetric()).toBe(false); + expect(p.canManageOrgSettings()).toBe(false); + expect(p.canManageTeam()).toBe(false); + expect(p.canPublishFeature(projectResource, envs)).toBe(false); + expect(p.canReviewFeatureDrafts(projectResource)).toBe(false); + expect(p.canRunExperiment(projectResource, envs)).toBe(false); + expect(p.canRunExperimentQueries(projectsResource)).toBe(true); + expect(p.canRunFactQueries(projectsResource)).toBe(true); + expect(p.canRunHealthQueries(projectsResource)).toBe(true); + expect(p.canRunMetricQueries(projectsResource)).toBe(true); + expect(p.canRunPastExperimentQueries(projectsResource)).toBe(true); + expect(p.canRunSchemaQueries(projectsResource)).toBe(true); + expect(p.canRunTestQueries(projectsResource)).toBe(true); + expect(p.canSuperDeleteReport()).toBe(false); + expect(p.canUpdateArchetype()).toBe(false); + expect(p.canUpdateAttribute(projectsResource, updates)).toBe(false); + expect(p.canUpdateDataSourceParams(projectsResource)).toBe(false); + expect(p.canUpdateDataSourceSettings(projectsResource)).toBe(true); + expect(p.canUpdateDimension()).toBe(true); + expect(p.canUpdateEventWebhook()).toBe(false); + expect(p.canUpdateExperiment(projectResource, updates)).toBe(true); + expect(p.canUpdateFactMetric(projectsResource, updates)).toBe(true); + expect(p.canUpdateFactTable(projectsResource, updates)).toBe(true); + expect(p.canUpdateFeature(projectResource, updates)).toBe(false); + expect(p.canUpdateIdea(projectResource, updates)).toBe(true); + expect(p.canUpdateMetric(projectsResource, updates)).toBe(true); + expect(p.canUpdateNamespace()).toBe(false); + expect(p.canUpdatePresentation()).toBe(true); + expect(p.canUpdateProject(project)).toBe(false); + expect(p.canUpdateReport(projectResource)).toBe(true); + expect(p.canUpdateSDKWebhook()).toBe(false); + expect(p.canUpdateSavedGroup()).toBe(false); + expect(p.canUpdateSegment()).toBe(true); + expect(p.canUpdateSomeProjects()).toBe(false); + expect(p.canUpdateVisualChange(projectResource)).toBe(true); + expect(p.canViewAttributeModal()).toBe(false); + expect(p.canViewCreateDataSourceModal()).toBe(false); + expect(p.canViewCreateFactTableModal()).toBe(true); + expect(p.canViewEditFactTableModal(projectsResource)).toBe(true); + expect(p.canViewEventWebhook()).toBe(false); + expect(p.canViewEvents()).toBe(false); + expect(p.canViewExperimentModal()).toBe(true); + expect(p.canViewFeatureModal()).toBe(false); + expect(p.canViewIdeaModal()).toBe(true); + expect(p.canViewReportModal()).toBe(true); + expect(p.canCreateAndUpdateFactFilter(projectsResource)).toBe(true); + expect(p.canDeleteFactFilter(projectsResource)).toBe(true); + expect(p.canCreateOrUpdateEnvironment(environmentsResource)).toBe(false); + expect(p.canDeleteEnvironment(environmentsResource)).toBe(false); + expect(p.canViewCreateSDKConnectionModal(project)).toBe(false); + expect(p.canCreateSDKConnection({ projects, environment })).toBe(false); + expect(p.canUpdateSDKConnection({ projects, environment }, updates)).toBe( + false + ); + expect(p.canDeleteSDKConnection({ projects, environment })).toBe(false); + expect(p.canReadSingleProjectResource(project)).toBe(true); + expect(p.canReadMultiProjectResource(projects)).toBe(true); + }); + + it("has correct permissions for experimenter", () => { + const p = getPermissions("experimenter"); + expect(p.canAddComment(projects)).toBe(true); + expect(p.canBypassApprovalChecks(projectResource)).toBe(false); + expect(p.canCreateAndUpdateTag()).toBe(true); + expect(p.canCreateApiKey()).toBe(false); + expect(p.canCreateArchetype()).toBe(true); + expect(p.canCreateAttribute(projectsResource)).toBe(true); + expect(p.canCreateDataSource(projectsResource)).toBe(false); + expect(p.canCreateDimension()).toBe(true); + expect(p.canCreateEventWebhook()).toBe(false); + expect(p.canCreateExperiment(projectResource)).toBe(true); + expect(p.canCreateFactMetric(projectsResource)).toBe(true); + expect(p.canCreateFactTable(projectsResource)).toBe(true); + expect(p.canCreateFeature(projectResource)).toBe(true); + expect(p.canCreateIdea(projectResource)).toBe(true); + expect(p.canCreateMetric(projectsResource)).toBe(true); + expect(p.canCreateNamespace()).toBe(true); + expect(p.canCreatePresentation()).toBe(true); + expect(p.canCreateProjects()).toBe(false); + expect(p.canCreateReport(projectResource)).toBe(true); + expect(p.canCreateSDKWebhook()).toBe(false); + expect(p.canCreateSavedGroup()).toBe(true); + expect(p.canCreateSegment()).toBe(true); + expect(p.canCreateVisualChange(projectResource)).toBe(true); + expect(p.canDeleteApiKey()).toBe(false); + expect(p.canDeleteArchetype()).toBe(true); + expect(p.canDeleteAttribute(projectsResource)).toBe(true); + expect(p.canDeleteDataSource(projectsResource)).toBe(false); + expect(p.canDeleteDimension()).toBe(true); + expect(p.canDeleteEventWebhook()).toBe(false); + expect(p.canDeleteExperiment(projectResource)).toBe(true); + expect(p.canDeleteFactMetric(projectsResource)).toBe(true); + expect(p.canDeleteFactTable(projectsResource)).toBe(true); + expect(p.canDeleteFeature(projectResource)).toBe(true); + expect(p.canDeleteIdea(projectResource)).toBe(true); + expect(p.canDeleteMetric(projectsResource)).toBe(true); + expect(p.canDeleteNamespace()).toBe(true); + expect(p.canDeletePresentation()).toBe(true); + expect(p.canDeleteProject(project)).toBe(false); + expect(p.canDeleteReport(projectResource)).toBe(true); + expect(p.canDeleteSDKWebhook()).toBe(false); + expect(p.canDeleteSavedGroup()).toBe(true); + expect(p.canDeleteSegment()).toBe(true); + expect(p.canDeleteTag()).toBe(true); + expect(p.canManageBilling()).toBe(false); + expect(p.canManageFeatureDrafts(projectResource)).toBe(true); + expect(p.canManageIntegrations()).toBe(false); + expect(p.canManageNorthStarMetric()).toBe(false); + expect(p.canManageOrgSettings()).toBe(false); + expect(p.canManageTeam()).toBe(false); + expect(p.canPublishFeature(projectResource, envs)).toBe(true); + expect(p.canReviewFeatureDrafts(projectResource)).toBe(true); + expect(p.canRunExperiment(projectResource, envs)).toBe(true); + expect(p.canRunExperimentQueries(projectsResource)).toBe(true); + expect(p.canRunFactQueries(projectsResource)).toBe(true); + expect(p.canRunHealthQueries(projectsResource)).toBe(true); + expect(p.canRunMetricQueries(projectsResource)).toBe(true); + expect(p.canRunPastExperimentQueries(projectsResource)).toBe(true); + expect(p.canRunSchemaQueries(projectsResource)).toBe(true); + expect(p.canRunTestQueries(projectsResource)).toBe(true); + expect(p.canSuperDeleteReport()).toBe(false); + expect(p.canUpdateArchetype()).toBe(true); + expect(p.canUpdateAttribute(projectsResource, updates)).toBe(true); + expect(p.canUpdateDataSourceParams(projectsResource)).toBe(false); + expect(p.canUpdateDataSourceSettings(projectsResource)).toBe(true); + expect(p.canUpdateDimension()).toBe(true); + expect(p.canUpdateEventWebhook()).toBe(false); + expect(p.canUpdateExperiment(projectResource, updates)).toBe(true); + expect(p.canUpdateFactMetric(projectsResource, updates)).toBe(true); + expect(p.canUpdateFactTable(projectsResource, updates)).toBe(true); + expect(p.canUpdateFeature(projectResource, updates)).toBe(true); + expect(p.canUpdateIdea(projectResource, updates)).toBe(true); + expect(p.canUpdateMetric(projectsResource, updates)).toBe(true); + expect(p.canUpdateNamespace()).toBe(true); + expect(p.canUpdatePresentation()).toBe(true); + expect(p.canUpdateProject(project)).toBe(false); + expect(p.canUpdateReport(projectResource)).toBe(true); + expect(p.canUpdateSDKWebhook()).toBe(false); + expect(p.canUpdateSavedGroup()).toBe(true); + expect(p.canUpdateSegment()).toBe(true); + expect(p.canUpdateSomeProjects()).toBe(false); + expect(p.canUpdateVisualChange(projectResource)).toBe(true); + expect(p.canViewAttributeModal()).toBe(true); + expect(p.canViewCreateDataSourceModal()).toBe(false); + expect(p.canViewCreateFactTableModal()).toBe(true); + expect(p.canViewEditFactTableModal(projectsResource)).toBe(true); + expect(p.canViewEventWebhook()).toBe(false); + expect(p.canViewEvents()).toBe(false); + expect(p.canViewExperimentModal()).toBe(true); + expect(p.canViewFeatureModal()).toBe(true); + expect(p.canViewIdeaModal()).toBe(true); + expect(p.canViewReportModal()).toBe(true); + expect(p.canCreateAndUpdateFactFilter(projectsResource)).toBe(true); + expect(p.canDeleteFactFilter(projectsResource)).toBe(true); + expect(p.canCreateOrUpdateEnvironment(environmentsResource)).toBe(true); + expect(p.canDeleteEnvironment(environmentsResource)).toBe(true); + expect(p.canViewCreateSDKConnectionModal(project)).toBe(true); + expect(p.canCreateSDKConnection({ projects, environment })).toBe(true); + expect(p.canUpdateSDKConnection({ projects, environment }, updates)).toBe( + true + ); + expect(p.canDeleteSDKConnection({ projects, environment })).toBe(true); + expect(p.canReadSingleProjectResource(project)).toBe(true); + expect(p.canReadMultiProjectResource(projects)).toBe(true); + }); + + it("has correct permissions for admin", () => { + const p = getPermissions("admin"); + expect(p.canAddComment(projects)).toBe(true); + expect(p.canBypassApprovalChecks(projectResource)).toBe(true); + expect(p.canCreateAndUpdateTag()).toBe(true); + expect(p.canCreateApiKey()).toBe(true); + expect(p.canCreateArchetype()).toBe(true); + expect(p.canCreateAttribute(projectsResource)).toBe(true); + expect(p.canCreateDataSource(projectsResource)).toBe(true); + expect(p.canCreateDimension()).toBe(true); + expect(p.canCreateEventWebhook()).toBe(true); + expect(p.canCreateExperiment(projectResource)).toBe(true); + expect(p.canCreateFactMetric(projectsResource)).toBe(true); + expect(p.canCreateFactTable(projectsResource)).toBe(true); + expect(p.canCreateFeature(projectResource)).toBe(true); + expect(p.canCreateIdea(projectResource)).toBe(true); + expect(p.canCreateMetric(projectsResource)).toBe(true); + expect(p.canCreateNamespace()).toBe(true); + expect(p.canCreatePresentation()).toBe(true); + expect(p.canCreateProjects()).toBe(true); + expect(p.canCreateReport(projectResource)).toBe(true); + expect(p.canCreateSDKWebhook()).toBe(true); + expect(p.canCreateSavedGroup()).toBe(true); + expect(p.canCreateSegment()).toBe(true); + expect(p.canCreateVisualChange(projectResource)).toBe(true); + expect(p.canDeleteApiKey()).toBe(true); + expect(p.canDeleteArchetype()).toBe(true); + expect(p.canDeleteAttribute(projectsResource)).toBe(true); + expect(p.canDeleteDataSource(projectsResource)).toBe(true); + expect(p.canDeleteDimension()).toBe(true); + expect(p.canDeleteEventWebhook()).toBe(true); + expect(p.canDeleteExperiment(projectResource)).toBe(true); + expect(p.canDeleteFactMetric(projectsResource)).toBe(true); + expect(p.canDeleteFactTable(projectsResource)).toBe(true); + expect(p.canDeleteFeature(projectResource)).toBe(true); + expect(p.canDeleteIdea(projectResource)).toBe(true); + expect(p.canDeleteMetric(projectsResource)).toBe(true); + expect(p.canDeleteNamespace()).toBe(true); + expect(p.canDeletePresentation()).toBe(true); + expect(p.canDeleteProject(project)).toBe(true); + expect(p.canDeleteReport(projectResource)).toBe(true); + expect(p.canDeleteSDKWebhook()).toBe(true); + expect(p.canDeleteSavedGroup()).toBe(true); + expect(p.canDeleteSegment()).toBe(true); + expect(p.canDeleteTag()).toBe(true); + expect(p.canManageBilling()).toBe(true); + expect(p.canManageFeatureDrafts(projectResource)).toBe(true); + expect(p.canManageIntegrations()).toBe(true); + expect(p.canManageNorthStarMetric()).toBe(true); + expect(p.canManageOrgSettings()).toBe(true); + expect(p.canManageTeam()).toBe(true); + expect(p.canPublishFeature(projectResource, envs)).toBe(true); + expect(p.canReviewFeatureDrafts(projectResource)).toBe(true); + expect(p.canRunExperiment(projectResource, envs)).toBe(true); + expect(p.canRunExperimentQueries(projectsResource)).toBe(true); + expect(p.canRunFactQueries(projectsResource)).toBe(true); + expect(p.canRunHealthQueries(projectsResource)).toBe(true); + expect(p.canRunMetricQueries(projectsResource)).toBe(true); + expect(p.canRunPastExperimentQueries(projectsResource)).toBe(true); + expect(p.canRunSchemaQueries(projectsResource)).toBe(true); + expect(p.canRunTestQueries(projectsResource)).toBe(true); + expect(p.canSuperDeleteReport()).toBe(true); + expect(p.canUpdateArchetype()).toBe(true); + expect(p.canUpdateAttribute(projectsResource, updates)).toBe(true); + expect(p.canUpdateDataSourceParams(projectsResource)).toBe(true); + expect(p.canUpdateDataSourceSettings(projectsResource)).toBe(true); + expect(p.canUpdateDimension()).toBe(true); + expect(p.canUpdateEventWebhook()).toBe(true); + expect(p.canUpdateExperiment(projectResource, updates)).toBe(true); + expect(p.canUpdateFactMetric(projectsResource, updates)).toBe(true); + expect(p.canUpdateFactTable(projectsResource, updates)).toBe(true); + expect(p.canUpdateFeature(projectResource, updates)).toBe(true); + expect(p.canUpdateIdea(projectResource, updates)).toBe(true); + expect(p.canUpdateMetric(projectsResource, updates)).toBe(true); + expect(p.canUpdateNamespace()).toBe(true); + expect(p.canUpdatePresentation()).toBe(true); + expect(p.canUpdateProject(project)).toBe(true); + expect(p.canUpdateReport(projectResource)).toBe(true); + expect(p.canUpdateSDKWebhook()).toBe(true); + expect(p.canUpdateSavedGroup()).toBe(true); + expect(p.canUpdateSegment()).toBe(true); + expect(p.canUpdateSomeProjects()).toBe(true); + expect(p.canUpdateVisualChange(projectResource)).toBe(true); + expect(p.canViewAttributeModal()).toBe(true); + expect(p.canViewCreateDataSourceModal()).toBe(true); + expect(p.canViewCreateFactTableModal()).toBe(true); + expect(p.canViewEditFactTableModal(projectsResource)).toBe(true); + expect(p.canViewEventWebhook()).toBe(true); + expect(p.canViewEvents()).toBe(true); + expect(p.canViewExperimentModal()).toBe(true); + expect(p.canViewFeatureModal()).toBe(true); + expect(p.canViewIdeaModal()).toBe(true); + expect(p.canViewReportModal()).toBe(true); + expect(p.canCreateAndUpdateFactFilter(projectsResource)).toBe(true); + expect(p.canDeleteFactFilter(projectsResource)).toBe(true); + expect(p.canCreateOrUpdateEnvironment(environmentsResource)).toBe(true); + expect(p.canDeleteEnvironment(environmentsResource)).toBe(true); + expect(p.canViewCreateSDKConnectionModal(project)).toBe(true); + expect(p.canCreateSDKConnection({ projects, environment })).toBe(true); + expect(p.canUpdateSDKConnection({ projects, environment }, updates)).toBe( + true + ); + expect(p.canDeleteSDKConnection({ projects, environment })).toBe(true); + expect(p.canReadSingleProjectResource(project)).toBe(true); + expect(p.canReadMultiProjectResource(projects)).toBe(true); + }); +});