Schema policy checks using graphql-eslint (#1730)
5
.changeset/rare-spies-melt.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@graphql-hive/cli': minor
|
||||
---
|
||||
|
||||
Added support for new warnings feature during `schema:check` commands
|
||||
7
.vscode/terminals.json
vendored
|
|
@ -37,6 +37,13 @@
|
|||
"cwd": "packages/services/schema",
|
||||
"command": "pnpm dev"
|
||||
},
|
||||
{
|
||||
"name": "policy:dev",
|
||||
"description": "Run policy service",
|
||||
"open": true,
|
||||
"cwd": "packages/services/policy",
|
||||
"command": "pnpm dev"
|
||||
},
|
||||
{
|
||||
"name": "usage-estimator:dev",
|
||||
"description": "Run Usage Estimator Service",
|
||||
|
|
|
|||
12
codegen.cjs
|
|
@ -41,6 +41,8 @@ const config = {
|
|||
'../shared/mappers#SchemaChangeConnection as SchemaChangeConnectionMapper',
|
||||
SchemaErrorConnection:
|
||||
'../shared/mappers#SchemaErrorConnection as SchemaErrorConnectionMapper',
|
||||
SchemaWarningConnection:
|
||||
'../shared/mappers#SchemaWarningConnection as SchemaWarningConnectionMapper',
|
||||
OrganizationConnection:
|
||||
'../shared/mappers#OrganizationConnection as OrganizationConnectionMapper',
|
||||
UserConnection: '../shared/mappers#UserConnection as UserConnectionMapper',
|
||||
|
|
@ -107,6 +109,8 @@ const config = {
|
|||
'../shared/entities#OrganizationInvitation as OrganizationInvitationMapper',
|
||||
OIDCIntegration: '../shared/entities#OIDCIntegration as OIDCIntegrationMapper',
|
||||
User: '../shared/entities#User as UserMapper',
|
||||
SchemaPolicy: '../shared/entities#SchemaPolicy as SchemaPolicyMapper',
|
||||
SchemaPolicyRule: '../shared/entities#SchemaPolicyAvailableRuleObject',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -122,6 +126,7 @@ const config = {
|
|||
scalars: {
|
||||
DateTime: 'string',
|
||||
SafeInt: 'number',
|
||||
JSONSchemaObject: 'json-schema-typed#JSONSchema',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -134,6 +139,13 @@ const config = {
|
|||
'!./packages/web/app/pages/api/github/setup-callback.ts',
|
||||
],
|
||||
preset: 'client',
|
||||
config: {
|
||||
scalars: {
|
||||
DateTime: 'string',
|
||||
SafeInt: 'number',
|
||||
JSONSchemaObject: 'json-schema-typed#JSONSchema',
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
},
|
||||
// CLI
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ const nodeVersion = readFileSync(join(rootDir, '/.node-version')).toString();
|
|||
export const targetFromNodeVersion = () => {
|
||||
const clean = nodeVersion.trim().split('.');
|
||||
|
||||
return `node${clean[0]}`;
|
||||
return `node${clean[0]}` as any;
|
||||
};
|
||||
|
||||
export const watchEntryPlugin = () => {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { deployGraphQL } from './services/graphql';
|
|||
import { deployKafka } from './services/kafka';
|
||||
import { deployMetrics } from './services/observability';
|
||||
import { deployCloudflarePolice } from './services/police';
|
||||
import { deploySchemaPolicy } from './services/policy';
|
||||
import { deployProxy } from './services/proxy';
|
||||
import { deployRateLimit } from './services/rate-limit';
|
||||
import { deployRedis } from './services/redis';
|
||||
|
|
@ -206,6 +207,13 @@ const schemaApi = deploySchema({
|
|||
broker: cfBroker,
|
||||
});
|
||||
|
||||
const schemaPolicyApi = deploySchemaPolicy({
|
||||
image: dockerImages.getImageId('policy', imagesTag),
|
||||
imagePullSecret,
|
||||
release: imagesTag,
|
||||
deploymentEnv,
|
||||
});
|
||||
|
||||
const supertokensApiKey = new random.RandomPassword('supertokens-api-key', {
|
||||
length: 31,
|
||||
special: false,
|
||||
|
|
@ -241,6 +249,7 @@ const graphqlApi = deployGraphQL({
|
|||
tokens: tokensApi,
|
||||
webhooks: webhooksApi,
|
||||
schema: schemaApi,
|
||||
schemaPolicy: schemaPolicyApi,
|
||||
dbMigrations,
|
||||
redis: redisApi,
|
||||
usage: usageApi,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { CDN } from './cf-cdn';
|
|||
import { Clickhouse } from './clickhouse';
|
||||
import { DbMigrations } from './db-migrations';
|
||||
import { Emails } from './emails';
|
||||
import { SchemaPolicy } from './policy';
|
||||
import { RateLimitService } from './rate-limit';
|
||||
import { Redis } from './redis';
|
||||
import { Schema } from './schema';
|
||||
|
|
@ -37,6 +38,7 @@ export function deployGraphQL({
|
|||
tokens,
|
||||
webhooks,
|
||||
schema,
|
||||
schemaPolicy,
|
||||
cdn,
|
||||
redis,
|
||||
usage,
|
||||
|
|
@ -58,6 +60,7 @@ export function deployGraphQL({
|
|||
tokens: Tokens;
|
||||
webhooks: Webhooks;
|
||||
schema: Schema;
|
||||
schemaPolicy: SchemaPolicy;
|
||||
redis: Redis;
|
||||
cdn: CDN;
|
||||
cdnAuthPrivateKey: Output<string>;
|
||||
|
|
@ -131,6 +134,7 @@ export function deployGraphQL({
|
|||
TOKENS_ENDPOINT: serviceLocalEndpoint(tokens.service),
|
||||
WEBHOOKS_ENDPOINT: serviceLocalEndpoint(webhooks.service),
|
||||
SCHEMA_ENDPOINT: serviceLocalEndpoint(schema.service),
|
||||
SCHEMA_POLICY_ENDPOINT: serviceLocalEndpoint(schemaPolicy.service),
|
||||
WEB_APP_URL: `https://${deploymentEnv.DEPLOYED_DNS}/`,
|
||||
// CDN
|
||||
CDN_CF: '1',
|
||||
|
|
|
|||
37
deployment/services/policy.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import * as k8s from '@pulumi/kubernetes';
|
||||
import * as pulumi from '@pulumi/pulumi';
|
||||
import { DeploymentEnvironment } from '../types';
|
||||
import { ServiceDeployment } from '../utils/service-deployment';
|
||||
|
||||
const commonConfig = new pulumi.Config('common');
|
||||
const commonEnv = commonConfig.requireObject<Record<string, string>>('env');
|
||||
|
||||
export type SchemaPolicy = ReturnType<typeof deploySchemaPolicy>;
|
||||
|
||||
export function deploySchemaPolicy({
|
||||
deploymentEnv,
|
||||
release,
|
||||
image,
|
||||
imagePullSecret,
|
||||
}: {
|
||||
image: string;
|
||||
release: string;
|
||||
deploymentEnv: DeploymentEnvironment;
|
||||
imagePullSecret: k8s.core.v1.Secret;
|
||||
}) {
|
||||
return new ServiceDeployment('schema-policy-service', {
|
||||
image,
|
||||
imagePullSecret,
|
||||
env: {
|
||||
...deploymentEnv,
|
||||
...commonEnv,
|
||||
SENTRY: commonEnv.SENTRY_ENABLED,
|
||||
RELEASE: release,
|
||||
},
|
||||
readinessProbe: '/_readiness',
|
||||
livenessProbe: '/_health',
|
||||
exposesMetrics: true,
|
||||
replicas: 1,
|
||||
pdb: true,
|
||||
}).deploy();
|
||||
}
|
||||
|
|
@ -215,6 +215,8 @@ services:
|
|||
condition: service_healthy
|
||||
schema:
|
||||
condition: service_healthy
|
||||
policy:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- '8082:3001'
|
||||
environment:
|
||||
|
|
@ -235,6 +237,7 @@ services:
|
|||
TOKENS_ENDPOINT: http://tokens:3003
|
||||
WEBHOOKS_ENDPOINT: http://webhooks:3005
|
||||
SCHEMA_ENDPOINT: http://schema:3002
|
||||
SCHEMA_POLICY_ENDPOINT: http://policy:3012
|
||||
EMAILS_ENDPOINT: http://emails:3011
|
||||
ENCRYPTION_SECRET: '${HIVE_ENCRYPTION_SECRET}'
|
||||
WEB_APP_URL: '${HIVE_APP_BASE_URL}'
|
||||
|
|
@ -251,6 +254,17 @@ services:
|
|||
CDN_API_BASE_URL: 'http://localhost:8082'
|
||||
AUTH_ORGANIZATION_OIDC: '1'
|
||||
|
||||
policy:
|
||||
image: '${DOCKER_REGISTRY}policy${DOCKER_TAG}'
|
||||
networks:
|
||||
- 'stack'
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3012
|
||||
|
||||
schema:
|
||||
image: '${DOCKER_REGISTRY}schema${DOCKER_TAG}'
|
||||
networks:
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ target "schema" {
|
|||
inherits = ["service-base", get_target()]
|
||||
context = "${PWD}/packages/services/schema/dist"
|
||||
args = {
|
||||
IMAGE_TITLE = "graphql-hive/rate-limit"
|
||||
IMAGE_TITLE = "graphql-hive/schema"
|
||||
IMAGE_DESCRIPTION = "The schema service of the GraphQL Hive project."
|
||||
PORT = "3002"
|
||||
HEALTHCHECK_CMD = "wget --spider -q http://127.0.0.1:$${PORT}/_readiness"
|
||||
|
|
@ -140,6 +140,23 @@ target "schema" {
|
|||
]
|
||||
}
|
||||
|
||||
target "policy" {
|
||||
inherits = ["service-base", get_target()]
|
||||
context = "${PWD}/packages/services/policy/dist"
|
||||
args = {
|
||||
IMAGE_TITLE = "graphql-hive/policy"
|
||||
IMAGE_DESCRIPTION = "The policy service of the GraphQL Hive project."
|
||||
PORT = "3012"
|
||||
HEALTHCHECK_CMD = "wget --spider -q http://127.0.0.1:$${PORT}/_readiness"
|
||||
}
|
||||
tags = [
|
||||
local_image_tag("policy"),
|
||||
stable_image_tag("policy"),
|
||||
image_tag("policy", COMMIT_SHA),
|
||||
image_tag("policy", BRANCH_NAME)
|
||||
]
|
||||
}
|
||||
|
||||
target "server" {
|
||||
inherits = ["service-base", get_target()]
|
||||
context = "${PWD}/packages/services/server/dist"
|
||||
|
|
@ -331,6 +348,7 @@ group "build" {
|
|||
"emails",
|
||||
"rate-limit",
|
||||
"schema",
|
||||
"policy",
|
||||
"storage",
|
||||
"tokens",
|
||||
"usage-estimator",
|
||||
|
|
@ -349,6 +367,7 @@ group "integration-tests" {
|
|||
"emails",
|
||||
"rate-limit",
|
||||
"schema",
|
||||
"policy",
|
||||
"storage",
|
||||
"tokens",
|
||||
"usage-estimator",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
#/bin/sh
|
||||
set -e
|
||||
set -e
|
||||
|
||||
echo "💀 Killing all running Docker containers..."
|
||||
docker kill $(docker ps -q)
|
||||
docker kill $(docker ps -q) || true
|
||||
|
||||
echo "🧹 Clearing existing Docker volumes..."
|
||||
rm -rf ../docker/.hive || true
|
||||
|
||||
echo "🧹 Clearing old artifacts..."
|
||||
rm -rf ../packages/migrations/dist || true
|
||||
|
||||
echo "✨ Clearing unused Docker images and volumes..."
|
||||
docker system prune -f
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
"human-id": "4.0.0",
|
||||
"ioredis": "5.3.2",
|
||||
"slonik": "30.4.4",
|
||||
"strip-ansi": "7.0.1",
|
||||
"zod": "3.21.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -6,10 +6,14 @@ export async function prepareProject(
|
|||
model: RegistryModel = RegistryModel.Modern,
|
||||
) {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { organization, createProject, setFeatureFlag } = await createOrg();
|
||||
const { project, createToken, target, targets } = await createProject(projectType, {
|
||||
useLegacyRegistryModels: model === RegistryModel.Legacy,
|
||||
});
|
||||
const { organization, createProject, setFeatureFlag, setOrganizationSchemaPolicy } =
|
||||
await createOrg();
|
||||
const { project, createToken, target, targets, setProjectSchemaPolicy } = await createProject(
|
||||
projectType,
|
||||
{
|
||||
useLegacyRegistryModels: model === RegistryModel.Legacy,
|
||||
},
|
||||
);
|
||||
|
||||
// Create a token with write rights
|
||||
const { secret: readwriteToken } = await createToken({
|
||||
|
|
@ -31,6 +35,10 @@ export async function prepareProject(
|
|||
targets,
|
||||
target,
|
||||
fetchVersions,
|
||||
policy: {
|
||||
setOrganizationSchemaPolicy,
|
||||
setProjectSchemaPolicy,
|
||||
},
|
||||
tokens: {
|
||||
registry: {
|
||||
readwrite: readwriteToken,
|
||||
|
|
|
|||
191
integration-tests/testkit/schema-policy.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { RuleInstanceSeverityLevel, SchemaPolicyInput } from '@app/gql/graphql';
|
||||
import { graphql } from './gql';
|
||||
|
||||
export const TargetCalculatedPolicy = graphql(`
|
||||
query TargetCalculatedPolicy($selector: TargetSelectorInput!) {
|
||||
target(selector: $selector) {
|
||||
id
|
||||
schemaPolicy {
|
||||
mergedRules {
|
||||
...SchemaPolicyRuleInstanceFields
|
||||
}
|
||||
projectPolicy {
|
||||
id
|
||||
rules {
|
||||
...SchemaPolicyRuleInstanceFields
|
||||
}
|
||||
}
|
||||
organizationPolicy {
|
||||
id
|
||||
allowOverrides
|
||||
rules {
|
||||
...SchemaPolicyRuleInstanceFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment SchemaPolicyRuleInstanceFields on SchemaPolicyRuleInstance {
|
||||
rule {
|
||||
id
|
||||
}
|
||||
severity
|
||||
configuration
|
||||
}
|
||||
`);
|
||||
|
||||
export const OrganizationAndProjectsWithSchemaPolicy = graphql(`
|
||||
query OrganizationAndProjectsWithSchemaPolicy($organization: ID!) {
|
||||
organization(selector: { organization: $organization }) {
|
||||
organization {
|
||||
id
|
||||
schemaPolicy {
|
||||
id
|
||||
}
|
||||
projects {
|
||||
nodes {
|
||||
id
|
||||
schemaPolicy {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const UpdateSchemaPolicyForOrganization = graphql(`
|
||||
mutation UpdateSchemaPolicyForOrganization(
|
||||
$selector: OrganizationSelectorInput!
|
||||
$policy: SchemaPolicyInput!
|
||||
$allowOverrides: Boolean!
|
||||
) {
|
||||
updateSchemaPolicyForOrganization(
|
||||
selector: $selector
|
||||
policy: $policy
|
||||
allowOverrides: $allowOverrides
|
||||
) {
|
||||
error {
|
||||
message
|
||||
}
|
||||
ok {
|
||||
organization {
|
||||
id
|
||||
schemaPolicy {
|
||||
id
|
||||
}
|
||||
}
|
||||
updatedPolicy {
|
||||
id
|
||||
allowOverrides
|
||||
updatedAt
|
||||
rules {
|
||||
rule {
|
||||
id
|
||||
}
|
||||
severity
|
||||
configuration
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const UpdateSchemaPolicyForProject = graphql(`
|
||||
mutation UpdateSchemaPolicyForProject(
|
||||
$selector: ProjectSelectorInput!
|
||||
$policy: SchemaPolicyInput!
|
||||
) {
|
||||
updateSchemaPolicyForProject(selector: $selector, policy: $policy) {
|
||||
error {
|
||||
message
|
||||
}
|
||||
ok {
|
||||
project {
|
||||
id
|
||||
schemaPolicy {
|
||||
id
|
||||
}
|
||||
}
|
||||
updatedPolicy {
|
||||
id
|
||||
updatedAt
|
||||
rules {
|
||||
rule {
|
||||
id
|
||||
}
|
||||
severity
|
||||
configuration
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const INVALID_RULE_POLICY = {
|
||||
rules: [
|
||||
{
|
||||
ruleId: 'require-kamil-to-merge-my-prs',
|
||||
severity: RuleInstanceSeverityLevel.Error,
|
||||
configuration: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const INVALID_RULE_CONFIG_POLICY: SchemaPolicyInput = {
|
||||
rules: [
|
||||
{
|
||||
ruleId: 'require-description',
|
||||
severity: RuleInstanceSeverityLevel.Error,
|
||||
configuration: {
|
||||
nonExisting: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const EMPTY_RULE_CONFIG_POLICY: SchemaPolicyInput = {
|
||||
rules: [
|
||||
{
|
||||
ruleId: 'require-description',
|
||||
severity: RuleInstanceSeverityLevel.Error,
|
||||
configuration: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const VALID_POLICY: SchemaPolicyInput = {
|
||||
rules: [
|
||||
{
|
||||
ruleId: 'require-description',
|
||||
severity: RuleInstanceSeverityLevel.Error,
|
||||
configuration: {
|
||||
types: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const DESCRIPTION_RULE = {
|
||||
ruleId: 'description-style',
|
||||
severity: RuleInstanceSeverityLevel.Warning,
|
||||
configuration: { style: 'inline' },
|
||||
};
|
||||
|
||||
export const LONGER_VALID_POLICY: SchemaPolicyInput = {
|
||||
rules: [
|
||||
{
|
||||
ruleId: 'require-description',
|
||||
severity: RuleInstanceSeverityLevel.Error,
|
||||
configuration: {
|
||||
types: true,
|
||||
FieldDefinition: true,
|
||||
},
|
||||
},
|
||||
DESCRIPTION_RULE,
|
||||
],
|
||||
};
|
||||
|
|
@ -4,6 +4,7 @@ import {
|
|||
ProjectAccessScope,
|
||||
ProjectType,
|
||||
RegistryModel,
|
||||
SchemaPolicyInput,
|
||||
TargetAccessScope,
|
||||
} from '@app/gql/graphql';
|
||||
import { authenticate, userEmail } from './auth';
|
||||
|
|
@ -37,6 +38,8 @@ import {
|
|||
updateRegistryModel,
|
||||
updateSchemaVersionStatus,
|
||||
} from './flow';
|
||||
import { execute } from './graphql';
|
||||
import { UpdateSchemaPolicyForOrganization, UpdateSchemaPolicyForProject } from './schema-policy';
|
||||
import { collect, CollectedOperation } from './usage';
|
||||
import { generateUnique } from './utils';
|
||||
|
||||
|
|
@ -83,6 +86,21 @@ export function initSeed() {
|
|||
WHERE id = ${organization.id}
|
||||
`);
|
||||
},
|
||||
async setOrganizationSchemaPolicy(policy: SchemaPolicyInput, allowOverrides: boolean) {
|
||||
const result = await execute({
|
||||
document: UpdateSchemaPolicyForOrganization,
|
||||
variables: {
|
||||
allowOverrides,
|
||||
selector: {
|
||||
organization: organization.cleanId,
|
||||
},
|
||||
policy,
|
||||
},
|
||||
authToken: ownerToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
return result.updateSchemaPolicyForOrganization;
|
||||
},
|
||||
async fetchOrganizationInfo() {
|
||||
const result = await getOrganization(organization.cleanId, ownerToken).then(r =>
|
||||
r.expectNoGraphQLErrors(),
|
||||
|
|
@ -153,6 +171,21 @@ export function initSeed() {
|
|||
project,
|
||||
targets,
|
||||
target,
|
||||
async setProjectSchemaPolicy(policy: SchemaPolicyInput) {
|
||||
const result = await execute({
|
||||
document: UpdateSchemaPolicyForProject,
|
||||
variables: {
|
||||
selector: {
|
||||
organization: organization.cleanId,
|
||||
project: project.cleanId,
|
||||
},
|
||||
policy,
|
||||
},
|
||||
authToken: ownerToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
return result.updateSchemaPolicyForProject;
|
||||
},
|
||||
async removeTokens(tokenIds: string[]) {
|
||||
return await deleteTokens(
|
||||
{
|
||||
|
|
|
|||
290
integration-tests/tests/api/policy/policy-check.spec.ts
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
import stripAnsi from 'strip-ansi';
|
||||
import { prepareProject } from 'testkit/registry-models';
|
||||
import { ProjectType, RuleInstanceSeverityLevel, SchemaPolicyInput } from '@app/gql/graphql';
|
||||
import { createCLI } from '../../../testkit/cli';
|
||||
|
||||
export const createPolicy = (level: RuleInstanceSeverityLevel): SchemaPolicyInput => ({
|
||||
rules: [
|
||||
{
|
||||
ruleId: 'require-description',
|
||||
severity: level,
|
||||
configuration: {
|
||||
types: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe('Schema policy checks', () => {
|
||||
describe('model: composite', () => {
|
||||
it('Fedearation project with policy with only warnings, should check only the part that was changed', async () => {
|
||||
const { tokens, policy } = await prepareProject(ProjectType.Federation);
|
||||
await policy.setOrganizationSchemaPolicy(
|
||||
createPolicy(RuleInstanceSeverityLevel.Warning),
|
||||
true,
|
||||
);
|
||||
const cli = await createCLI(tokens.registry);
|
||||
|
||||
await cli.publish({
|
||||
sdl: /* GraphQL */ `
|
||||
type Product @key(fields: "id") {
|
||||
id: ID!
|
||||
title: String
|
||||
url: String
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
product(id: ID!): Product
|
||||
}
|
||||
`,
|
||||
serviceName: 'products',
|
||||
serviceUrl: 'https://api.com/products',
|
||||
expect: 'latest-composable',
|
||||
});
|
||||
|
||||
await cli.publish({
|
||||
sdl: /* GraphQL */ `
|
||||
type User @key(fields: "id") {
|
||||
id: ID!
|
||||
name: String!
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
user(id: ID!): User
|
||||
}
|
||||
`,
|
||||
serviceName: 'users',
|
||||
serviceUrl: 'https://api.com/users',
|
||||
expect: 'latest-composable',
|
||||
});
|
||||
|
||||
const rawMessage = await cli.check({
|
||||
sdl: /* GraphQL */ `
|
||||
type User @key(fields: "id") {
|
||||
id: ID!
|
||||
name: String!
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
user(id: ID!): User
|
||||
}
|
||||
`,
|
||||
serviceName: 'users',
|
||||
expect: 'approved',
|
||||
});
|
||||
const message = stripAnsi(rawMessage);
|
||||
|
||||
expect(message).toContain(`Detected 1 warning`);
|
||||
expect(message).toMatchInlineSnapshot(`
|
||||
v No changes
|
||||
⚠ Detected 1 warning
|
||||
- Description is required for type User (source: policy-require-description)
|
||||
`);
|
||||
});
|
||||
|
||||
it('Fedearation project with policy with , should check only the part that was changed', async () => {
|
||||
const { tokens, policy } = await prepareProject(ProjectType.Federation);
|
||||
await policy.setOrganizationSchemaPolicy(createPolicy(RuleInstanceSeverityLevel.Error), true);
|
||||
const cli = await createCLI(tokens.registry);
|
||||
|
||||
await cli.publish({
|
||||
sdl: /* GraphQL */ `
|
||||
type Product @key(fields: "id") {
|
||||
id: ID!
|
||||
title: String
|
||||
url: String
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
product(id: ID!): Product
|
||||
}
|
||||
`,
|
||||
serviceName: 'products',
|
||||
serviceUrl: 'https://api.com/products',
|
||||
expect: 'latest-composable',
|
||||
});
|
||||
|
||||
await cli.publish({
|
||||
sdl: /* GraphQL */ `
|
||||
type User @key(fields: "id") {
|
||||
id: ID!
|
||||
name: String!
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
user(id: ID!): User
|
||||
}
|
||||
`,
|
||||
serviceName: 'users',
|
||||
serviceUrl: 'https://api.com/users',
|
||||
expect: 'latest-composable',
|
||||
});
|
||||
|
||||
const rawMessage = await cli.check({
|
||||
sdl: /* GraphQL */ `
|
||||
type User @key(fields: "id") {
|
||||
id: ID!
|
||||
name: String!
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
user(id: ID!): User
|
||||
}
|
||||
`,
|
||||
serviceName: 'users',
|
||||
expect: 'rejected',
|
||||
});
|
||||
const message = stripAnsi(rawMessage);
|
||||
|
||||
expect(message).toContain(`Detected 1 error`);
|
||||
expect(message.split('\n').slice(1).join('\n')).toMatchInlineSnapshot(`
|
||||
✖ Detected 1 error
|
||||
|
||||
- Description is required for type User (source: policy-require-description)
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('model: single', () => {
|
||||
test('Single with policy with only warnings', async () => {
|
||||
const { tokens, policy } = await prepareProject(ProjectType.Single);
|
||||
await policy.setOrganizationSchemaPolicy(
|
||||
createPolicy(RuleInstanceSeverityLevel.Warning),
|
||||
true,
|
||||
);
|
||||
const cli = await createCLI(tokens.registry);
|
||||
|
||||
await cli.publish({
|
||||
sdl: /* GraphQL */ `
|
||||
type Query {
|
||||
foo: String!
|
||||
}
|
||||
`,
|
||||
expect: 'latest-composable',
|
||||
});
|
||||
|
||||
const rawMessage = await cli.check({
|
||||
sdl: /* GraphQL */ `
|
||||
type Query {
|
||||
foo: String!
|
||||
user: User!
|
||||
}
|
||||
|
||||
type User {
|
||||
name: String!
|
||||
}
|
||||
`,
|
||||
expect: 'approved',
|
||||
});
|
||||
const message = stripAnsi(rawMessage);
|
||||
|
||||
expect(message).toContain(`Detected 2 warnings`);
|
||||
expect(message).toMatchInlineSnapshot(`
|
||||
i Detected 2 changes
|
||||
- Type User was added
|
||||
- Field user was added to object type Query
|
||||
⚠ Detected 2 warnings
|
||||
- Description is required for type Query (source: policy-require-description)
|
||||
- Description is required for type User (source: policy-require-description)
|
||||
`);
|
||||
});
|
||||
|
||||
test('Single with policy with only errors', async () => {
|
||||
const { tokens, policy } = await prepareProject(ProjectType.Single);
|
||||
await policy.setOrganizationSchemaPolicy(createPolicy(RuleInstanceSeverityLevel.Error), true);
|
||||
const cli = await createCLI(tokens.registry);
|
||||
|
||||
await cli.publish({
|
||||
sdl: /* GraphQL */ `
|
||||
type Query {
|
||||
foo: String!
|
||||
}
|
||||
`,
|
||||
expect: 'latest-composable',
|
||||
});
|
||||
|
||||
const rawMessage = await cli.check({
|
||||
sdl: /* GraphQL */ `
|
||||
type Query {
|
||||
foo: String!
|
||||
user: User!
|
||||
}
|
||||
|
||||
type User {
|
||||
name: String!
|
||||
}
|
||||
`,
|
||||
expect: 'rejected',
|
||||
});
|
||||
const message = stripAnsi(rawMessage);
|
||||
|
||||
expect(message).toContain(`Detected 2 errors`);
|
||||
expect(message.split('\n').slice(1).join('\n')).toMatchInlineSnapshot(`
|
||||
✖ Detected 2 errors
|
||||
|
||||
- Description is required for type Query (source: policy-require-description)
|
||||
- Description is required for type User (source: policy-require-description)
|
||||
`);
|
||||
});
|
||||
|
||||
test('Single with policy with both errors and warning', async () => {
|
||||
const { tokens, policy } = await prepareProject(ProjectType.Single);
|
||||
await policy.setOrganizationSchemaPolicy(
|
||||
{
|
||||
rules: [
|
||||
{
|
||||
ruleId: 'require-description',
|
||||
severity: RuleInstanceSeverityLevel.Error,
|
||||
configuration: {
|
||||
types: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
ruleId: 'require-deprecation-reason',
|
||||
severity: RuleInstanceSeverityLevel.Warning,
|
||||
},
|
||||
],
|
||||
},
|
||||
true,
|
||||
);
|
||||
const cli = await createCLI(tokens.registry);
|
||||
|
||||
await cli.publish({
|
||||
sdl: /* GraphQL */ `
|
||||
type Query {
|
||||
foo: String!
|
||||
}
|
||||
`,
|
||||
expect: 'latest-composable',
|
||||
});
|
||||
|
||||
const rawMessage = await cli.check({
|
||||
sdl: /* GraphQL */ `
|
||||
type Query {
|
||||
foo: String! @deprecated(reason: "")
|
||||
user: User!
|
||||
}
|
||||
|
||||
type User {
|
||||
name: String!
|
||||
}
|
||||
`,
|
||||
expect: 'rejected',
|
||||
});
|
||||
const message = stripAnsi(rawMessage);
|
||||
|
||||
expect(message).toContain(`Detected 2 errors`);
|
||||
expect(message).toContain(`Detected 1 warning`);
|
||||
expect(message.split('\n').slice(1).join('\n')).toMatchInlineSnapshot(`
|
||||
✖ Detected 2 errors
|
||||
|
||||
- Description is required for type Query (source: policy-require-description)
|
||||
- Description is required for type User (source: policy-require-description)
|
||||
|
||||
⚠ Detected 1 warning
|
||||
|
||||
- Deprecation reason is required for field foo in type Query. (source: policy-require-deprecation-reason)
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
436
integration-tests/tests/api/policy/policy-crud.spec.ts
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
import { ProjectType } from '@app/gql/graphql';
|
||||
import { execute } from '../../../testkit/graphql';
|
||||
import {
|
||||
DESCRIPTION_RULE,
|
||||
EMPTY_RULE_CONFIG_POLICY,
|
||||
INVALID_RULE_CONFIG_POLICY,
|
||||
INVALID_RULE_POLICY,
|
||||
LONGER_VALID_POLICY,
|
||||
OrganizationAndProjectsWithSchemaPolicy,
|
||||
TargetCalculatedPolicy,
|
||||
VALID_POLICY,
|
||||
} from '../../../testkit/schema-policy';
|
||||
import { initSeed } from '../../../testkit/seed';
|
||||
|
||||
describe('Policy CRUD', () => {
|
||||
describe('Target level', () => {
|
||||
test.concurrent(
|
||||
'Should return empty policy when project and org does not have one',
|
||||
async () => {
|
||||
const { createOrg, ownerToken } = await initSeed().createOwner();
|
||||
const { organization, createProject } = await createOrg();
|
||||
const { project, target } = await createProject(ProjectType.Single);
|
||||
|
||||
const result = await execute({
|
||||
document: TargetCalculatedPolicy,
|
||||
variables: {
|
||||
selector: {
|
||||
organization: organization.cleanId,
|
||||
project: project.cleanId,
|
||||
target: target.cleanId,
|
||||
},
|
||||
},
|
||||
authToken: ownerToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
expect(result.target?.schemaPolicy).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
test('Should return a valid policy when only org has a policy', async () => {
|
||||
const { createOrg, ownerToken } = await initSeed().createOwner();
|
||||
const { organization, createProject, setOrganizationSchemaPolicy } = await createOrg();
|
||||
const { project, target } = await createProject(ProjectType.Single);
|
||||
|
||||
const upsertResult = await setOrganizationSchemaPolicy(VALID_POLICY, true);
|
||||
expect(upsertResult.error).toBeNull();
|
||||
|
||||
const result = await execute({
|
||||
document: TargetCalculatedPolicy,
|
||||
variables: {
|
||||
selector: {
|
||||
organization: organization.cleanId,
|
||||
project: project.cleanId,
|
||||
target: target.cleanId,
|
||||
},
|
||||
},
|
||||
authToken: ownerToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
expect(result.target?.schemaPolicy).toBeDefined();
|
||||
expect(result.target?.schemaPolicy?.organizationPolicy).toBeDefined();
|
||||
expect(result.target?.schemaPolicy?.projectPolicy).toBeNull();
|
||||
expect(result.target?.schemaPolicy?.mergedRules).toEqual(
|
||||
result.target?.schemaPolicy?.organizationPolicy?.rules,
|
||||
);
|
||||
});
|
||||
|
||||
test('Should return a valid policy when only project has a policy', async () => {
|
||||
const { createOrg, ownerToken } = await initSeed().createOwner();
|
||||
const { organization, createProject } = await createOrg();
|
||||
const { project, target, setProjectSchemaPolicy } = await createProject(ProjectType.Single);
|
||||
|
||||
await setProjectSchemaPolicy(VALID_POLICY);
|
||||
|
||||
const result = await execute({
|
||||
document: TargetCalculatedPolicy,
|
||||
variables: {
|
||||
selector: {
|
||||
organization: organization.cleanId,
|
||||
project: project.cleanId,
|
||||
target: target.cleanId,
|
||||
},
|
||||
},
|
||||
authToken: ownerToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
expect(result.target?.schemaPolicy).toBeDefined();
|
||||
expect(result.target?.schemaPolicy?.projectPolicy).toBeDefined();
|
||||
expect(result.target?.schemaPolicy?.organizationPolicy).toBeNull();
|
||||
expect(result.target?.schemaPolicy?.mergedRules).toEqual(
|
||||
result.target?.schemaPolicy?.projectPolicy?.rules,
|
||||
);
|
||||
});
|
||||
|
||||
test('Should return a valid policy when both project and org has policies - with no overrides', async () => {
|
||||
const { createOrg, ownerToken } = await initSeed().createOwner();
|
||||
const { organization, createProject, setOrganizationSchemaPolicy } = await createOrg();
|
||||
const { project, target, setProjectSchemaPolicy } = await createProject(ProjectType.Single);
|
||||
|
||||
await setOrganizationSchemaPolicy(VALID_POLICY, true);
|
||||
await setProjectSchemaPolicy({ rules: [DESCRIPTION_RULE] });
|
||||
|
||||
const result = await execute({
|
||||
document: TargetCalculatedPolicy,
|
||||
variables: {
|
||||
selector: {
|
||||
organization: organization.cleanId,
|
||||
project: project.cleanId,
|
||||
target: target.cleanId,
|
||||
},
|
||||
},
|
||||
authToken: ownerToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
expect(result.target?.schemaPolicy).toBeDefined();
|
||||
expect(result.target?.schemaPolicy?.projectPolicy).toBeDefined();
|
||||
expect(result.target?.schemaPolicy?.organizationPolicy).toBeDefined();
|
||||
expect(result.target?.schemaPolicy?.mergedRules).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
configuration: {
|
||||
types: true,
|
||||
},
|
||||
rule: {
|
||||
id: require-description,
|
||||
},
|
||||
severity: ERROR,
|
||||
},
|
||||
{
|
||||
configuration: {
|
||||
style: inline,
|
||||
},
|
||||
rule: {
|
||||
id: description-style,
|
||||
},
|
||||
severity: WARNING,
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('Should return a valid policy when both project and org has policies - with overrides', async () => {
|
||||
const { createOrg, ownerToken } = await initSeed().createOwner();
|
||||
const { organization, createProject, setOrganizationSchemaPolicy } = await createOrg();
|
||||
const { project, target, setProjectSchemaPolicy } = await createProject(ProjectType.Single);
|
||||
|
||||
await setOrganizationSchemaPolicy(VALID_POLICY, true);
|
||||
await setProjectSchemaPolicy(LONGER_VALID_POLICY);
|
||||
|
||||
const result = await execute({
|
||||
document: TargetCalculatedPolicy,
|
||||
variables: {
|
||||
selector: {
|
||||
organization: organization.cleanId,
|
||||
project: project.cleanId,
|
||||
target: target.cleanId,
|
||||
},
|
||||
},
|
||||
authToken: ownerToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
expect(result.target?.schemaPolicy).toBeDefined();
|
||||
expect(result.target?.schemaPolicy?.projectPolicy).toBeDefined();
|
||||
expect(result.target?.schemaPolicy?.organizationPolicy).toBeDefined();
|
||||
expect(result.target?.schemaPolicy?.mergedRules).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
configuration: {
|
||||
FieldDefinition: true,
|
||||
types: true,
|
||||
},
|
||||
rule: {
|
||||
id: require-description,
|
||||
},
|
||||
severity: ERROR,
|
||||
},
|
||||
{
|
||||
configuration: {
|
||||
style: inline,
|
||||
},
|
||||
rule: {
|
||||
id: description-style,
|
||||
},
|
||||
severity: WARNING,
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('Should ignore project policy when policy was set and org is not allowing overrides', async () => {
|
||||
const { createOrg, ownerToken } = await initSeed().createOwner();
|
||||
const { organization, createProject, setOrganizationSchemaPolicy } = await createOrg();
|
||||
const { project, target, setProjectSchemaPolicy } = await createProject(ProjectType.Single);
|
||||
|
||||
// First, set the org policy while it still can
|
||||
await setProjectSchemaPolicy(LONGER_VALID_POLICY);
|
||||
|
||||
// Now, mark the org as not allowing overrides
|
||||
await setOrganizationSchemaPolicy(VALID_POLICY, false);
|
||||
|
||||
const result = await execute({
|
||||
document: TargetCalculatedPolicy,
|
||||
variables: {
|
||||
selector: {
|
||||
organization: organization.cleanId,
|
||||
project: project.cleanId,
|
||||
target: target.cleanId,
|
||||
},
|
||||
},
|
||||
authToken: ownerToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
expect(result.target?.schemaPolicy).toBeDefined();
|
||||
expect(result.target?.schemaPolicy?.projectPolicy).toBeDefined();
|
||||
expect(result.target?.schemaPolicy?.organizationPolicy).toBeDefined();
|
||||
// Should have only org policy now
|
||||
expect(result.target?.schemaPolicy?.mergedRules).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
configuration: {
|
||||
types: true,
|
||||
},
|
||||
rule: {
|
||||
id: require-description,
|
||||
},
|
||||
severity: ERROR,
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Project level', () => {
|
||||
test.concurrent(
|
||||
'creating a project should NOT create a record in the database for the policy',
|
||||
async () => {
|
||||
const { createOrg, ownerToken } = await initSeed().createOwner();
|
||||
const { organization, createProject } = await createOrg();
|
||||
await createProject(ProjectType.Single);
|
||||
|
||||
const result = await execute({
|
||||
document: OrganizationAndProjectsWithSchemaPolicy,
|
||||
variables: {
|
||||
organization: organization.cleanId,
|
||||
},
|
||||
authToken: ownerToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
expect(result.organization?.organization.schemaPolicy).toBe(null);
|
||||
expect(result.organization?.organization.projects.nodes).toHaveLength(1);
|
||||
expect(result.organization?.organization.projects.nodes[0].schemaPolicy).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
test('upserting a configuration works as expected on a project level', async () => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { createProject } = await createOrg();
|
||||
const { project, setProjectSchemaPolicy } = await createProject(ProjectType.Single);
|
||||
|
||||
const upsertResult = await setProjectSchemaPolicy(VALID_POLICY);
|
||||
expect(upsertResult.error).toBeNull();
|
||||
expect(upsertResult.ok).toBeDefined();
|
||||
expect(upsertResult.ok?.updatedPolicy.id).toBe(`PROJECT_${project.id}`);
|
||||
expect(upsertResult.ok?.updatedPolicy.id).toBe(upsertResult.ok?.project?.schemaPolicy?.id);
|
||||
expect(upsertResult.ok?.updatedPolicy.rules).toHaveLength(1);
|
||||
expect(upsertResult.ok?.updatedPolicy.rules[0]).toMatchInlineSnapshot(`
|
||||
{
|
||||
configuration: {
|
||||
types: true,
|
||||
},
|
||||
rule: {
|
||||
id: require-description,
|
||||
},
|
||||
severity: ERROR,
|
||||
}
|
||||
`);
|
||||
|
||||
const upsertAgainResult = await setProjectSchemaPolicy(LONGER_VALID_POLICY);
|
||||
|
||||
expect(upsertAgainResult.error).toBeNull();
|
||||
expect(upsertAgainResult.ok).toBeDefined();
|
||||
// To make sure upsert works
|
||||
expect(upsertResult.ok?.updatedPolicy.id).toBe(upsertAgainResult.ok?.updatedPolicy?.id);
|
||||
expect(upsertAgainResult.ok?.updatedPolicy.id).toBe(
|
||||
upsertAgainResult.ok?.project?.schemaPolicy?.id,
|
||||
);
|
||||
expect(upsertAgainResult.ok?.updatedPolicy.rules).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
configuration: {
|
||||
style: inline,
|
||||
},
|
||||
rule: {
|
||||
id: description-style,
|
||||
},
|
||||
severity: WARNING,
|
||||
},
|
||||
{
|
||||
configuration: {
|
||||
FieldDefinition: true,
|
||||
types: true,
|
||||
},
|
||||
rule: {
|
||||
id: require-description,
|
||||
},
|
||||
severity: ERROR,
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('project level update is rejected when org does not allow to override', async () => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { createProject, setOrganizationSchemaPolicy } = await createOrg();
|
||||
const { setProjectSchemaPolicy } = await createProject(ProjectType.Single);
|
||||
|
||||
const orgResult = await setOrganizationSchemaPolicy(VALID_POLICY, false);
|
||||
expect(orgResult.error).toBeNull();
|
||||
expect(orgResult.ok).toBeDefined();
|
||||
|
||||
const result = await setProjectSchemaPolicy(LONGER_VALID_POLICY);
|
||||
expect(result.ok).toBeNull();
|
||||
expect(result.error?.message).toBe(
|
||||
`Organization policy does not allow overrides for schema policy at the project level.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Org level', () => {
|
||||
test.concurrent(
|
||||
'creating a org should NOT create a record in the database for the policy',
|
||||
async () => {
|
||||
const { createOrg, ownerToken } = await initSeed().createOwner();
|
||||
const { organization, createProject } = await createOrg();
|
||||
await createProject(ProjectType.Single);
|
||||
|
||||
const result = await execute({
|
||||
document: OrganizationAndProjectsWithSchemaPolicy,
|
||||
variables: {
|
||||
organization: organization.cleanId,
|
||||
},
|
||||
authToken: ownerToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
expect(result.organization?.organization.projects.nodes).toHaveLength(1);
|
||||
expect(result.organization?.organization.projects.nodes[0].schemaPolicy).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
test.concurrent('invalid rule name is rejected with an error', async () => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { createProject, setOrganizationSchemaPolicy } = await createOrg();
|
||||
await createProject(ProjectType.Single);
|
||||
|
||||
const upsertResult = await setOrganizationSchemaPolicy(INVALID_RULE_POLICY, true);
|
||||
expect(upsertResult.ok).toBeNull();
|
||||
expect(upsertResult.error).toBeDefined();
|
||||
expect(upsertResult.error?.message).toContain('Unkonwn rule name passed');
|
||||
});
|
||||
|
||||
test.concurrent('invalid rule config is rejected with an error', async () => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { createProject, setOrganizationSchemaPolicy } = await createOrg();
|
||||
await createProject(ProjectType.Single);
|
||||
|
||||
const upsertResult = await setOrganizationSchemaPolicy(INVALID_RULE_CONFIG_POLICY, true);
|
||||
expect(upsertResult.ok).toBeNull();
|
||||
expect(upsertResult.error).toBeDefined();
|
||||
expect(upsertResult.error?.message).toBe(
|
||||
'Failed to validate rule "require-description" configuration: data/0 must NOT have additional properties',
|
||||
);
|
||||
});
|
||||
|
||||
test.concurrent('empty rule config is rejected with an error', async () => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { createProject, setOrganizationSchemaPolicy } = await createOrg();
|
||||
await createProject(ProjectType.Single);
|
||||
|
||||
const upsertResult = await setOrganizationSchemaPolicy(EMPTY_RULE_CONFIG_POLICY, true);
|
||||
expect(upsertResult.ok).toBeNull();
|
||||
expect(upsertResult.error).toBeDefined();
|
||||
expect(upsertResult.error?.message).toBe(
|
||||
'Failed to validate rule "require-description" configuration: data/0 must NOT have fewer than 1 properties',
|
||||
);
|
||||
});
|
||||
|
||||
test('updating an org policy for the should upsert the policy record in the database', async () => {
|
||||
const { createOrg, ownerToken } = await initSeed().createOwner();
|
||||
const { organization, createProject, setOrganizationSchemaPolicy } = await createOrg();
|
||||
await createProject(ProjectType.Single);
|
||||
|
||||
const result = await execute({
|
||||
document: OrganizationAndProjectsWithSchemaPolicy,
|
||||
variables: {
|
||||
organization: organization.cleanId,
|
||||
},
|
||||
authToken: ownerToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
expect(result.organization?.organization.schemaPolicy).toBe(null);
|
||||
expect(result.organization?.organization.projects.nodes).toHaveLength(1);
|
||||
expect(result.organization?.organization.projects.nodes[0].schemaPolicy).toBeNull();
|
||||
|
||||
const upsertResult = await setOrganizationSchemaPolicy(VALID_POLICY, true);
|
||||
|
||||
expect(upsertResult.error).toBeNull();
|
||||
expect(upsertResult.ok).toBeDefined();
|
||||
expect(upsertResult.ok?.updatedPolicy.id).toBe(`ORGANIZATION_${organization.id}`);
|
||||
expect(upsertResult.ok?.updatedPolicy.id).toBe(
|
||||
upsertResult.ok?.organization?.schemaPolicy?.id,
|
||||
);
|
||||
expect(upsertResult.ok?.updatedPolicy.rules).toHaveLength(1);
|
||||
expect(upsertResult.ok?.updatedPolicy.rules[0]).toMatchInlineSnapshot(`
|
||||
{
|
||||
configuration: {
|
||||
types: true,
|
||||
},
|
||||
rule: {
|
||||
id: require-description,
|
||||
},
|
||||
severity: ERROR,
|
||||
}
|
||||
`);
|
||||
|
||||
const upsertAgainResult = await setOrganizationSchemaPolicy(LONGER_VALID_POLICY, true);
|
||||
|
||||
expect(upsertAgainResult.error).toBeNull();
|
||||
expect(upsertAgainResult.ok).toBeDefined();
|
||||
// To make sure upsert works
|
||||
expect(upsertResult.ok?.updatedPolicy.id).toBe(upsertAgainResult.ok?.updatedPolicy?.id);
|
||||
expect(upsertAgainResult.ok?.updatedPolicy.id).toBe(
|
||||
upsertAgainResult.ok?.organization?.schemaPolicy?.id,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -7,7 +7,9 @@
|
|||
"paths": {
|
||||
"@hive/server": ["../packages/services/server/src/api.ts"],
|
||||
"@hive/storage": ["../packages/services/storage/src/index.ts"],
|
||||
"@hive/rate-limit": ["../packages/services/rate-limit/src/api.ts"]
|
||||
"@hive/rate-limit": ["../packages/services/rate-limit/src/api.ts"],
|
||||
"@app/gql/graphql": ["./testkit/gql/graphql.ts"],
|
||||
"@app/gql": ["./testkit/gql/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["testkit", "tests"]
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
"seed": "tsx scripts/seed-local-env.ts",
|
||||
"test": "vitest",
|
||||
"test:e2e": "CYPRESS_BASE_URL=$HIVE_APP_BASE_URL cypress run",
|
||||
"test:integration": "cd integration-tests && pnpm test:integration",
|
||||
"typecheck": "pnpm turbo typecheck --color",
|
||||
"upload-sourcemaps": "./scripts/upload-sourcemaps.sh",
|
||||
"workspace": "pnpm run --filter $1 $2"
|
||||
|
|
@ -69,7 +70,7 @@
|
|||
"bob-the-bundler": "6.0.0",
|
||||
"cypress": "12.11.0",
|
||||
"dotenv": "16.0.3",
|
||||
"eslint": "8.39.0",
|
||||
"eslint": "8.40.0",
|
||||
"eslint-plugin-cypress": "2.13.3",
|
||||
"eslint-plugin-hive": "file:./rules",
|
||||
"eslint-plugin-tailwindcss": "3.11.0",
|
||||
|
|
@ -105,7 +106,6 @@
|
|||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"tsup": "6.7.0",
|
||||
"@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.cd77847.0"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
|
|
@ -117,7 +117,9 @@
|
|||
"@apollo/federation@0.38.1": "patches/@apollo__federation@0.38.1.patch",
|
||||
"@octokit/webhooks-methods@3.0.1": "patches/@octokit__webhooks-methods@3.0.1.patch",
|
||||
"bullmq@3.12.0": "patches/bullmq@3.12.0.patch",
|
||||
"@theguild/editor@1.2.5": "patches/@theguild__editor@1.2.5.patch"
|
||||
"@theguild/editor@1.2.5": "patches/@theguild__editor@1.2.5.patch",
|
||||
"eslint@8.40.0": "patches/eslint@8.40.0.patch",
|
||||
"@graphql-eslint/eslint-plugin@3.18.0": "patches/@graphql-eslint__eslint-plugin@3.18.0.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
type Query {
|
||||
foo: Int!
|
||||
bar: String
|
||||
test: String
|
||||
}
|
||||
# test 3
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@ export default abstract class extends Command {
|
|||
this.log(colors.yellow(symbols.info), ...args);
|
||||
}
|
||||
|
||||
infoWarning(...args: any[]) {
|
||||
this.log(colors.yellow(symbols.warning), ...args);
|
||||
}
|
||||
|
||||
bolderize(msg: string) {
|
||||
const findSingleQuotes = /'([^']+)'/gim;
|
||||
const findDoubleQuotes = /"([^"]+)"/gim;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,15 @@ mutation schemaCheck($input: SchemaCheckInput!, $usesGitHubApp: Boolean!) {
|
|||
... on SchemaCheckSuccess @skip(if: $usesGitHubApp) {
|
||||
valid
|
||||
initial
|
||||
warnings {
|
||||
nodes {
|
||||
message
|
||||
source
|
||||
line
|
||||
column
|
||||
}
|
||||
total
|
||||
}
|
||||
changes {
|
||||
nodes {
|
||||
message
|
||||
|
|
@ -21,6 +30,15 @@ mutation schemaCheck($input: SchemaCheckInput!, $usesGitHubApp: Boolean!) {
|
|||
}
|
||||
total
|
||||
}
|
||||
warnings {
|
||||
nodes {
|
||||
message
|
||||
source
|
||||
line
|
||||
column
|
||||
}
|
||||
total
|
||||
}
|
||||
errors {
|
||||
nodes {
|
||||
message
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@ import { Errors, Flags } from '@oclif/core';
|
|||
import Command from '../../base-command';
|
||||
import { graphqlEndpoint } from '../../helpers/config';
|
||||
import { gitInfo } from '../../helpers/git';
|
||||
import { loadSchema, minifySchema, renderChanges, renderErrors } from '../../helpers/schema';
|
||||
import {
|
||||
loadSchema,
|
||||
minifySchema,
|
||||
renderChanges,
|
||||
renderErrors,
|
||||
renderWarnings,
|
||||
} from '../../helpers/schema';
|
||||
import { invariant } from '../../helpers/validation';
|
||||
|
||||
export default class SchemaCheck extends Command {
|
||||
|
|
@ -118,15 +124,28 @@ export default class SchemaCheck extends Command {
|
|||
renderChanges.call(this, changes);
|
||||
this.log('');
|
||||
}
|
||||
|
||||
const warnings = result.schemaCheck.warnings;
|
||||
if (warnings?.total) {
|
||||
renderWarnings.call(this, warnings);
|
||||
this.log('');
|
||||
}
|
||||
} else if (result.schemaCheck.__typename === 'SchemaCheckError') {
|
||||
const changes = result.schemaCheck.changes;
|
||||
const errors = result.schemaCheck.errors;
|
||||
const warnings = result.schemaCheck.warnings;
|
||||
renderErrors.call(this, errors);
|
||||
|
||||
if (warnings?.total) {
|
||||
renderWarnings.call(this, warnings);
|
||||
this.log('');
|
||||
}
|
||||
|
||||
if (changes && changes.total) {
|
||||
this.log('');
|
||||
renderChanges.call(this, changes);
|
||||
}
|
||||
|
||||
this.log('');
|
||||
|
||||
if (forceSafe) {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@ import { JsonFileLoader } from '@graphql-tools/json-file-loader';
|
|||
import { loadTypedefs } from '@graphql-tools/load';
|
||||
import { UrlLoader } from '@graphql-tools/url-loader';
|
||||
import baseCommand from '../base-command';
|
||||
import { CriticalityLevel, SchemaChangeConnection, SchemaErrorConnection } from '../sdk';
|
||||
import {
|
||||
CriticalityLevel,
|
||||
SchemaChangeConnection,
|
||||
SchemaErrorConnection,
|
||||
SchemaWarningConnection,
|
||||
} from '../sdk';
|
||||
|
||||
const indent = ' ';
|
||||
|
||||
|
|
@ -34,6 +39,20 @@ export function renderChanges(this: baseCommand, changes: SchemaChangeConnection
|
|||
});
|
||||
}
|
||||
|
||||
export function renderWarnings(this: baseCommand, warnings: SchemaWarningConnection) {
|
||||
this.log('');
|
||||
this.infoWarning(`Detected ${warnings.total} warning${warnings.total > 1 ? 's' : ''}`);
|
||||
this.log('');
|
||||
|
||||
warnings.nodes.forEach(warning => {
|
||||
const details = [warning.source ? `source: ${this.bolderize(warning.source)}` : undefined]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
this.log(indent, `- ${this.bolderize(warning.message)}${details ? ` (${details})` : ''}`);
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadSchema(file: string) {
|
||||
const sources = await loadTypedefs(file, {
|
||||
cwd: process.cwd(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
CREATE TYPE
|
||||
schema_policy_resource AS ENUM('ORGANIZATION', 'PROJECT');
|
||||
|
||||
CREATE TABLE
|
||||
public.schema_policy_config (
|
||||
resource_type schema_policy_resource NOT NULL,
|
||||
resource_id UUID NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
config JSONB NOT NULL,
|
||||
allow_overriding BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
PRIMARY KEY (resource_type, resource_id)
|
||||
);
|
||||
|
|
@ -1 +1,4 @@
|
|||
ALTER TABLE public.organizations ADD COLUMN feature_flags JSONB
|
||||
ALTER TABLE
|
||||
public.organizations
|
||||
ADD COLUMN
|
||||
feature_flags JSONB
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
raise 'down migration not implemented'
|
||||
|
|
@ -23,6 +23,11 @@ import { operationsModule } from './modules/operations';
|
|||
import { CLICKHOUSE_CONFIG, ClickHouseConfig } from './modules/operations/providers/tokens';
|
||||
import { organizationModule } from './modules/organization';
|
||||
import { persistedOperationModule } from './modules/persisted-operations';
|
||||
import { schemaPolicyModule } from './modules/policy';
|
||||
import {
|
||||
SCHEMA_POLICY_SERVICE_CONFIG,
|
||||
SchemaPolicyServiceConfig,
|
||||
} from './modules/policy/providers/tokens';
|
||||
import { projectModule } from './modules/project';
|
||||
import { rateLimitModule } from './modules/rate-limit';
|
||||
import {
|
||||
|
|
@ -78,6 +83,7 @@ const modules = [
|
|||
rateLimitModule,
|
||||
billingModule,
|
||||
oidcIntegrationsModule,
|
||||
schemaPolicyModule,
|
||||
];
|
||||
|
||||
export function createRegistry({
|
||||
|
|
@ -87,6 +93,7 @@ export function createRegistry({
|
|||
schemaService,
|
||||
usageEstimationService,
|
||||
rateLimitService,
|
||||
schemaPolicyService,
|
||||
logger,
|
||||
storage,
|
||||
clickHouse,
|
||||
|
|
@ -110,6 +117,7 @@ export function createRegistry({
|
|||
schemaService: SchemaServiceConfig;
|
||||
usageEstimationService: UsageEstimationServiceConfig;
|
||||
rateLimitService: RateLimitServiceConfig;
|
||||
schemaPolicyService: SchemaPolicyServiceConfig;
|
||||
githubApp: GitHubApplicationConfig | null;
|
||||
cdn: CDNConfig | null;
|
||||
s3: {
|
||||
|
|
@ -201,6 +209,11 @@ export function createRegistry({
|
|||
useValue: rateLimitService,
|
||||
scope: Scope.Singleton,
|
||||
},
|
||||
{
|
||||
provide: SCHEMA_POLICY_SERVICE_CONFIG,
|
||||
useValue: schemaPolicyService,
|
||||
scope: Scope.Singleton,
|
||||
},
|
||||
{
|
||||
provide: REDIS_CONFIG,
|
||||
useValue: redis,
|
||||
|
|
|
|||
13
packages/services/api/src/modules/policy/index.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { createModule } from 'graphql-modules';
|
||||
import { SchemaPolicyApiProvider } from './providers/schema-policy-api.provider';
|
||||
import { SchemaPolicyProvider } from './providers/schema-policy.provider';
|
||||
import { resolvers } from './resolvers';
|
||||
import typeDefs from './module.graphql';
|
||||
|
||||
export const schemaPolicyModule = createModule({
|
||||
id: 'policy',
|
||||
dirname: __dirname,
|
||||
typeDefs,
|
||||
resolvers,
|
||||
providers: [SchemaPolicyProvider, SchemaPolicyApiProvider],
|
||||
});
|
||||
98
packages/services/api/src/modules/policy/module.graphql.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { gql } from 'graphql-modules';
|
||||
|
||||
export default gql`
|
||||
enum SchemaPolicyLevel {
|
||||
ORGANIZATION
|
||||
PROJECT
|
||||
}
|
||||
|
||||
enum RuleInstanceSeverityLevel {
|
||||
OFF
|
||||
WARNING
|
||||
ERROR
|
||||
}
|
||||
|
||||
type SchemaPolicy {
|
||||
id: ID!
|
||||
rules: [SchemaPolicyRuleInstance!]!
|
||||
updatedAt: DateTime!
|
||||
allowOverrides: Boolean!
|
||||
}
|
||||
|
||||
type SchemaPolicyRuleInstance {
|
||||
rule: SchemaPolicyRule!
|
||||
severity: RuleInstanceSeverityLevel!
|
||||
configuration: JSON
|
||||
}
|
||||
|
||||
type SchemaPolicyRule {
|
||||
id: ID!
|
||||
description: String!
|
||||
recommended: Boolean!
|
||||
configJsonSchema: JSONSchemaObject
|
||||
documentationUrl: String
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
schemaPolicyRules: [SchemaPolicyRule!]!
|
||||
}
|
||||
|
||||
type UpdateSchemaPolicyResultError implements Error {
|
||||
message: String!
|
||||
code: String
|
||||
}
|
||||
|
||||
type UpdateSchemaPolicyResultOk {
|
||||
updatedPolicy: SchemaPolicy!
|
||||
organization: Organization
|
||||
project: Project
|
||||
}
|
||||
|
||||
type UpdateSchemaPolicyResult {
|
||||
ok: UpdateSchemaPolicyResultOk
|
||||
error: Error
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
updateSchemaPolicyForOrganization(
|
||||
selector: OrganizationSelectorInput!
|
||||
policy: SchemaPolicyInput!
|
||||
allowOverrides: Boolean!
|
||||
): UpdateSchemaPolicyResult!
|
||||
updateSchemaPolicyForProject(
|
||||
selector: ProjectSelectorInput!
|
||||
policy: SchemaPolicyInput!
|
||||
): UpdateSchemaPolicyResult!
|
||||
}
|
||||
|
||||
input SchemaPolicyInput {
|
||||
rules: [SchemaPolicyRuleInstanceInput!]!
|
||||
}
|
||||
|
||||
input SchemaPolicyRuleInstanceInput {
|
||||
ruleId: String!
|
||||
severity: RuleInstanceSeverityLevel!
|
||||
configuration: JSON
|
||||
}
|
||||
|
||||
extend type Organization {
|
||||
schemaPolicy: SchemaPolicy
|
||||
}
|
||||
|
||||
extend type Project {
|
||||
schemaPolicy: SchemaPolicy
|
||||
}
|
||||
|
||||
extend type Target {
|
||||
"""
|
||||
A merged representation of the schema policy, as inherited from the organization and project.
|
||||
"""
|
||||
schemaPolicy: TargetSchemaPolicy
|
||||
}
|
||||
|
||||
type TargetSchemaPolicy {
|
||||
organizationPolicy: SchemaPolicy
|
||||
projectPolicy: SchemaPolicy
|
||||
mergedRules: [SchemaPolicyRuleInstance!]!
|
||||
}
|
||||
`;
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import { Inject, Injectable, Scope } from 'graphql-modules';
|
||||
import type { AvailableRulesResponse, SchemaPolicyApi, SchemaPolicyApiInput } from '@hive/policy';
|
||||
import { createTRPCProxyClient, httpLink } from '@trpc/client';
|
||||
import { fetch } from '@whatwg-node/fetch';
|
||||
import { sentry } from '../../../shared/sentry';
|
||||
import { Logger } from '../../shared/providers/logger';
|
||||
import type { SchemaPolicyServiceConfig } from './tokens';
|
||||
import { SCHEMA_POLICY_SERVICE_CONFIG } from './tokens';
|
||||
|
||||
@Injectable({
|
||||
global: true,
|
||||
scope: Scope.Singleton,
|
||||
})
|
||||
export class SchemaPolicyApiProvider {
|
||||
private logger: Logger;
|
||||
private schemaPolicy;
|
||||
private cachedAvailableRules: AvailableRulesResponse | null = null;
|
||||
|
||||
constructor(
|
||||
rootLogger: Logger,
|
||||
@Inject(SCHEMA_POLICY_SERVICE_CONFIG)
|
||||
config: SchemaPolicyServiceConfig,
|
||||
) {
|
||||
this.logger = rootLogger.child({ service: 'SchemaPolicyApiProvider' });
|
||||
this.schemaPolicy = config.endpoint
|
||||
? createTRPCProxyClient<SchemaPolicyApi>({
|
||||
links: [
|
||||
httpLink({
|
||||
url: `${config.endpoint}/trpc`,
|
||||
fetch,
|
||||
}),
|
||||
],
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
@sentry('SchemaPolicyProvider.checkPolicy')
|
||||
checkPolicy(input: SchemaPolicyApiInput['checkPolicy']) {
|
||||
if (this.schemaPolicy === null) {
|
||||
this.logger.warn(
|
||||
`Unable to check schema-policy for input: %o , service information is not available`,
|
||||
input,
|
||||
);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
this.logger.debug(`Checking schema policy for target id="${input.target}"`);
|
||||
|
||||
return this.schemaPolicy.checkPolicy.mutate(input);
|
||||
}
|
||||
|
||||
@sentry('SchemaPolicyProvider.validateConfig')
|
||||
async validateConfig(input: SchemaPolicyApiInput['validateConfig']) {
|
||||
if (this.schemaPolicy === null) {
|
||||
this.logger.warn(
|
||||
`Unable to validate schema-policy for input: %o , service information is not available`,
|
||||
input,
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
this.logger.debug(`Checking schema policy config validity`);
|
||||
|
||||
return await this.schemaPolicy.validateConfig.query(input);
|
||||
}
|
||||
|
||||
@sentry('SchemaPolicyProvider.listAvailableRules')
|
||||
async listAvailableRules() {
|
||||
if (this.schemaPolicy === null) {
|
||||
this.logger.warn(
|
||||
`Unable to check schema-policy for input: %o , service information is not available`,
|
||||
);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
if (this.cachedAvailableRules === null) {
|
||||
this.logger.debug(`Fetching schema policy available rules`);
|
||||
this.cachedAvailableRules = await this.schemaPolicy.availableRules.query();
|
||||
}
|
||||
|
||||
return this.cachedAvailableRules;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
import { Injectable, Scope } from 'graphql-modules';
|
||||
import type { CheckPolicyResponse, PolicyConfigurationObject } from '@hive/policy';
|
||||
import { SchemaPolicy } from '../../../shared/entities';
|
||||
import { AuthManager } from '../../auth/providers/auth-manager';
|
||||
import { OrganizationAccessScope, ProjectAccessScope } from '../../auth/providers/scopes';
|
||||
import { Logger } from '../../shared/providers/logger';
|
||||
import {
|
||||
OrganizationSelector,
|
||||
ProjectSelector,
|
||||
Storage,
|
||||
TargetSelector,
|
||||
} from '../../shared/providers/storage';
|
||||
import { SchemaPolicyApiProvider } from './schema-policy-api.provider';
|
||||
|
||||
@Injectable({
|
||||
scope: Scope.Operation,
|
||||
global: true,
|
||||
})
|
||||
export class SchemaPolicyProvider {
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
rootLogger: Logger,
|
||||
private storage: Storage,
|
||||
private authManager: AuthManager,
|
||||
private api: SchemaPolicyApiProvider,
|
||||
) {
|
||||
this.logger = rootLogger.child({ service: 'SchemaPolicyProvider' });
|
||||
}
|
||||
|
||||
private mergePolicies(policies: SchemaPolicy[]): PolicyConfigurationObject {
|
||||
return policies.reduce((prev, policy) => {
|
||||
return {
|
||||
...prev,
|
||||
...policy.config,
|
||||
};
|
||||
}, {} as PolicyConfigurationObject);
|
||||
}
|
||||
|
||||
async getCalculatedPolicyForTarget(selector: TargetSelector): Promise<{
|
||||
orgLevel: SchemaPolicy | null;
|
||||
projectLevel: SchemaPolicy | null;
|
||||
mergedPolicy: PolicyConfigurationObject | null;
|
||||
}> {
|
||||
const relevantPolicies = await this.storage.findInheritedPolicies(selector);
|
||||
const orgLevel = relevantPolicies.find(p => p.resource === 'ORGANIZATION') ?? null;
|
||||
let projectLevel = relevantPolicies.find(p => p.resource === 'PROJECT') ?? null;
|
||||
|
||||
if (orgLevel && !orgLevel.allowOverrides) {
|
||||
projectLevel = null;
|
||||
}
|
||||
|
||||
// We want to make sure they are appliedin that order, to allow project level to override org level
|
||||
const policies = [orgLevel!, projectLevel!].filter(r => r !== null);
|
||||
|
||||
if (policies.length === 0) {
|
||||
return {
|
||||
mergedPolicy: null,
|
||||
orgLevel: null,
|
||||
projectLevel: null,
|
||||
};
|
||||
}
|
||||
|
||||
const mergedPolicy = this.mergePolicies(policies);
|
||||
|
||||
if (Object.keys(mergedPolicy).length === 0) {
|
||||
return {
|
||||
mergedPolicy: null,
|
||||
orgLevel,
|
||||
projectLevel,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mergedPolicy,
|
||||
orgLevel,
|
||||
projectLevel,
|
||||
};
|
||||
}
|
||||
|
||||
async checkPolicy(
|
||||
completeSchema: string,
|
||||
modifiedSdl: string,
|
||||
selector: TargetSelector,
|
||||
): Promise<
|
||||
| null
|
||||
| { success: true; warnings: CheckPolicyResponse }
|
||||
| {
|
||||
success: false;
|
||||
warnings: CheckPolicyResponse;
|
||||
errors: CheckPolicyResponse;
|
||||
}
|
||||
> {
|
||||
const { mergedPolicy } = await this.getCalculatedPolicyForTarget(selector);
|
||||
|
||||
if (!mergedPolicy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const results = await this.api.checkPolicy({
|
||||
target: selector.target,
|
||||
policy: mergedPolicy,
|
||||
schema: completeSchema,
|
||||
source: modifiedSdl,
|
||||
});
|
||||
|
||||
const warnings = results.filter(r => r.severity === 1);
|
||||
const errors = results.filter(r => r.severity === 2);
|
||||
|
||||
if (errors.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
warnings,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
async setOrganizationPolicy(
|
||||
selector: OrganizationSelector,
|
||||
policy: any,
|
||||
allowOverrides: boolean,
|
||||
) {
|
||||
await this.authManager.ensureOrganizationAccess({
|
||||
...selector,
|
||||
scope: OrganizationAccessScope.SETTINGS,
|
||||
});
|
||||
|
||||
return await this.storage.setSchemaPolicyForOrganization({
|
||||
organizationId: selector.organization,
|
||||
policy,
|
||||
allowOverrides,
|
||||
});
|
||||
}
|
||||
|
||||
async setProjectPolicy(selector: ProjectSelector, policy: any) {
|
||||
await this.authManager.ensureProjectAccess({
|
||||
...selector,
|
||||
scope: ProjectAccessScope.SETTINGS,
|
||||
});
|
||||
|
||||
return await this.storage.setSchemaPolicyForProject({
|
||||
projectId: selector.project,
|
||||
policy,
|
||||
});
|
||||
}
|
||||
|
||||
async getOrganizationPolicy(selector: OrganizationSelector) {
|
||||
await this.authManager.ensureOrganizationAccess({
|
||||
...selector,
|
||||
scope: OrganizationAccessScope.READ,
|
||||
});
|
||||
|
||||
return this.storage.getSchemaPolicyForOrganization(selector.organization);
|
||||
}
|
||||
|
||||
async getProjectPolicy(selector: ProjectSelector) {
|
||||
await this.authManager.ensureProjectAccess({
|
||||
...selector,
|
||||
scope: ProjectAccessScope.READ,
|
||||
});
|
||||
|
||||
return this.storage.getSchemaPolicyForProject(selector.project);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { InjectionToken } from 'graphql-modules';
|
||||
|
||||
export interface SchemaPolicyServiceConfig {
|
||||
endpoint: string | null;
|
||||
}
|
||||
|
||||
export const SCHEMA_POLICY_SERVICE_CONFIG = new InjectionToken<SchemaPolicyServiceConfig>(
|
||||
'schema-policy-service-config',
|
||||
);
|
||||
153
packages/services/api/src/modules/policy/resolvers.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { TRPCClientError } from '@trpc/client';
|
||||
import { OrganizationManager } from '../organization/providers/organization-manager';
|
||||
import { ProjectManager } from '../project/providers/project-manager';
|
||||
import { IdTranslator } from '../shared/providers/id-translator';
|
||||
import { PolicyModule } from './__generated__/types';
|
||||
import { SchemaPolicyApiProvider } from './providers/schema-policy-api.provider';
|
||||
import { SchemaPolicyProvider } from './providers/schema-policy.provider';
|
||||
import { formatTRPCErrors, policyInputToConfigObject, serializeSeverity } from './utils';
|
||||
|
||||
export const resolvers: PolicyModule.Resolvers = {
|
||||
SchemaPolicy: {
|
||||
id: policy => policy.id,
|
||||
allowOverrides: policy => policy.allowOverrides,
|
||||
rules: async (policy, _, { injector }) => {
|
||||
const availableRules = await injector.get(SchemaPolicyApiProvider).listAvailableRules();
|
||||
|
||||
return Object.entries(policy.config).map(([ruleId, config]) => ({
|
||||
rule: availableRules.find(r => r.name === ruleId)!,
|
||||
severity: serializeSeverity(config[0]),
|
||||
configuration: config[1] || null,
|
||||
}));
|
||||
},
|
||||
},
|
||||
Organization: {
|
||||
schemaPolicy: async (org, _, { injector }) =>
|
||||
injector.get(SchemaPolicyProvider).getOrganizationPolicy({
|
||||
organization: org.id,
|
||||
}),
|
||||
},
|
||||
Project: {
|
||||
schemaPolicy: async (project, _, { injector }) =>
|
||||
injector.get(SchemaPolicyProvider).getProjectPolicy({
|
||||
project: project.id,
|
||||
organization: project.orgId,
|
||||
}),
|
||||
},
|
||||
Target: {
|
||||
schemaPolicy: async (target, _, { injector }) => {
|
||||
const { mergedPolicy, orgLevel, projectLevel } = await injector
|
||||
.get(SchemaPolicyProvider)
|
||||
.getCalculatedPolicyForTarget({
|
||||
project: target.projectId,
|
||||
organization: target.orgId,
|
||||
target: target.id,
|
||||
});
|
||||
|
||||
if (!mergedPolicy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const availableRules = await injector.get(SchemaPolicyApiProvider).listAvailableRules();
|
||||
const rules = Object.entries(mergedPolicy).map(([ruleId, config]) => ({
|
||||
rule: availableRules.find(r => r.name === ruleId)!,
|
||||
severity: serializeSeverity(config[0]),
|
||||
configuration: config[1],
|
||||
}));
|
||||
|
||||
return {
|
||||
mergedRules: rules,
|
||||
organizationPolicy: orgLevel,
|
||||
projectPolicy: projectLevel,
|
||||
};
|
||||
},
|
||||
},
|
||||
SchemaPolicyRule: {
|
||||
id: r => r.name,
|
||||
description: r => r.description,
|
||||
configJsonSchema: r => r.schema,
|
||||
recommended: r => r.recommended,
|
||||
documentationUrl: r => r.url ?? null,
|
||||
},
|
||||
Query: {
|
||||
schemaPolicyRules: (_, args, { injector }) =>
|
||||
injector.get(SchemaPolicyApiProvider).listAvailableRules(),
|
||||
},
|
||||
Mutation: {
|
||||
updateSchemaPolicyForOrganization: async (
|
||||
_,
|
||||
{ selector, policy, allowOverrides },
|
||||
{ injector },
|
||||
) => {
|
||||
try {
|
||||
const organization = await injector.get(IdTranslator).translateOrganizationId(selector);
|
||||
const config = policyInputToConfigObject(policy);
|
||||
await injector.get(SchemaPolicyApiProvider).validateConfig({ config });
|
||||
const updatedPolicy = await injector
|
||||
.get(SchemaPolicyProvider)
|
||||
.setOrganizationPolicy({ organization }, config, allowOverrides);
|
||||
|
||||
return {
|
||||
ok: {
|
||||
updatedPolicy,
|
||||
organization: await injector.get(OrganizationManager).getOrganization({ organization }),
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof TRPCClientError) {
|
||||
return formatTRPCErrors(e);
|
||||
}
|
||||
|
||||
return {
|
||||
error: {
|
||||
__typename: 'UpdateSchemaPolicyResultError',
|
||||
message: (e as Error).message,
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
updateSchemaPolicyForProject: async (_, { selector, policy }, { injector }) => {
|
||||
try {
|
||||
const translator = injector.get(IdTranslator);
|
||||
const [organization, project] = await Promise.all([
|
||||
translator.translateOrganizationId(selector),
|
||||
translator.translateProjectId(selector),
|
||||
]);
|
||||
const organizationPolicy = await injector
|
||||
.get(SchemaPolicyProvider)
|
||||
.getOrganizationPolicy({ organization: organization });
|
||||
const allowOverrides = organizationPolicy === null || organizationPolicy.allowOverrides;
|
||||
|
||||
if (!allowOverrides) {
|
||||
throw new Error(
|
||||
`Organization policy does not allow overrides for schema policy at the project level.`,
|
||||
);
|
||||
}
|
||||
|
||||
const config = policyInputToConfigObject(policy);
|
||||
await injector.get(SchemaPolicyApiProvider).validateConfig({ config });
|
||||
const updatedPolicy = await injector
|
||||
.get(SchemaPolicyProvider)
|
||||
.setProjectPolicy({ organization, project }, config);
|
||||
|
||||
return {
|
||||
ok: {
|
||||
updatedPolicy,
|
||||
project: await injector.get(ProjectManager).getProject({ organization, project }),
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof TRPCClientError) {
|
||||
return formatTRPCErrors(e);
|
||||
}
|
||||
|
||||
return {
|
||||
error: {
|
||||
__typename: 'UpdateSchemaPolicyResultError',
|
||||
message: (e as Error).message,
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
64
packages/services/api/src/modules/policy/utils.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import type { SchemaPolicyApi } from '@hive/policy';
|
||||
import { TRPCClientError } from '@trpc/client';
|
||||
import { RuleInstanceSeverityLevel, SchemaPolicyInput } from '../../__generated__/types';
|
||||
|
||||
export function formatTRPCErrors(e: TRPCClientError<SchemaPolicyApi>) {
|
||||
if (e.data?.zodError) {
|
||||
return {
|
||||
error: {
|
||||
__typename: 'UpdateSchemaPolicyResultError' as const,
|
||||
message: e.data.formatted || e.message,
|
||||
code: 'VALIDATION_ERROR',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
error: {
|
||||
__typename: 'UpdateSchemaPolicyResultError' as const,
|
||||
message: e.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSeverity(severity: RuleInstanceSeverityLevel): number {
|
||||
switch (severity) {
|
||||
case 'ERROR':
|
||||
return 2;
|
||||
case 'WARNING':
|
||||
return 1;
|
||||
case 'OFF':
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeSeverity(value: string | number): RuleInstanceSeverityLevel {
|
||||
switch (value) {
|
||||
case 0:
|
||||
case 'off':
|
||||
case 'OFF':
|
||||
return 'OFF';
|
||||
case 1:
|
||||
case 'warn':
|
||||
case 'WARN':
|
||||
return 'WARNING';
|
||||
case 2:
|
||||
case 'error':
|
||||
case 'ERROR':
|
||||
return 'ERROR';
|
||||
default:
|
||||
throw new Error(`Invalid severity level: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function policyInputToConfigObject(policy: SchemaPolicyInput) {
|
||||
return policy.rules.reduce(
|
||||
(acc, r) => ({
|
||||
...acc,
|
||||
[r.ruleId]: r.configuration
|
||||
? [parseSeverity(r.severity), r.configuration]
|
||||
: [parseSeverity(r.severity)],
|
||||
}),
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
|
@ -273,16 +273,30 @@ export default gql`
|
|||
total: Int!
|
||||
}
|
||||
|
||||
type SchemaWarningConnection {
|
||||
nodes: [SchemaCheckWarning!]!
|
||||
total: Int!
|
||||
}
|
||||
|
||||
type SchemaCheckSuccess {
|
||||
valid: Boolean!
|
||||
initial: Boolean!
|
||||
changes: SchemaChangeConnection
|
||||
warnings: SchemaWarningConnection
|
||||
}
|
||||
|
||||
type SchemaCheckWarning {
|
||||
message: String!
|
||||
source: String
|
||||
line: Int
|
||||
column: Int
|
||||
}
|
||||
|
||||
type SchemaCheckError {
|
||||
valid: Boolean!
|
||||
changes: SchemaChangeConnection
|
||||
errors: SchemaErrorConnection!
|
||||
warnings: SchemaWarningConnection
|
||||
}
|
||||
|
||||
type GitHubSchemaCheckSuccess {
|
||||
|
|
@ -371,6 +385,7 @@ export default gql`
|
|||
enum SchemaCompareErrorDetailType {
|
||||
graphql
|
||||
composition
|
||||
policy
|
||||
}
|
||||
|
||||
type SchemaCompareErrorDetail {
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ export class CompositeLegacyModel {
|
|||
if (serviceNameCheck.status === 'failed') {
|
||||
return {
|
||||
conclusion: SchemaCheckConclusion.Failure,
|
||||
// Do we want to use this new "warning" field to let users know they should upgrade to new model?
|
||||
warnings: [],
|
||||
reasons: [
|
||||
{
|
||||
code: CheckFailureReasonCode.MissingServiceName,
|
||||
|
|
@ -101,7 +103,7 @@ export class CompositeLegacyModel {
|
|||
if (checksumCheck.status === 'completed' && checksumCheck.result === 'unchanged') {
|
||||
return {
|
||||
conclusion: SchemaCheckConclusion.Success,
|
||||
state: { initial, changes: null },
|
||||
state: { initial, changes: null, warnings: null },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -151,6 +153,7 @@ export class CompositeLegacyModel {
|
|||
|
||||
return {
|
||||
conclusion: SchemaCheckConclusion.Failure,
|
||||
warnings: [],
|
||||
reasons,
|
||||
};
|
||||
}
|
||||
|
|
@ -160,6 +163,7 @@ export class CompositeLegacyModel {
|
|||
state: {
|
||||
initial,
|
||||
changes: diffCheck.result?.changes ?? null,
|
||||
warnings: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ export class CompositeModel {
|
|||
if (serviceNameCheck.status === 'failed') {
|
||||
return {
|
||||
conclusion: SchemaCheckConclusion.Failure,
|
||||
warnings: [],
|
||||
reasons: [
|
||||
{
|
||||
code: CheckFailureReasonCode.MissingServiceName,
|
||||
|
|
@ -122,6 +123,7 @@ export class CompositeModel {
|
|||
conclusion: SchemaCheckConclusion.Success,
|
||||
state: {
|
||||
initial,
|
||||
warnings: null,
|
||||
changes: null,
|
||||
},
|
||||
};
|
||||
|
|
@ -132,7 +134,7 @@ export class CompositeModel {
|
|||
? this.federationOrchestrator
|
||||
: this.stitchingOrchestrator;
|
||||
|
||||
const [compositionCheck, diffCheck] = await Promise.all([
|
||||
const [compositionCheck, diffCheck, policyCheck] = await Promise.all([
|
||||
this.checks.composition({
|
||||
orchestrator,
|
||||
project,
|
||||
|
|
@ -147,9 +149,20 @@ export class CompositeModel {
|
|||
version: compareToLatest ? latest : latestComposable,
|
||||
includeUrlChanges: false,
|
||||
}),
|
||||
this.checks.policyCheck({
|
||||
orchestrator,
|
||||
project,
|
||||
selector,
|
||||
schemas,
|
||||
modifiedSdl: incoming.sdl,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (compositionCheck.status === 'failed' || diffCheck.status === 'failed') {
|
||||
if (
|
||||
compositionCheck.status === 'failed' ||
|
||||
diffCheck.status === 'failed' ||
|
||||
policyCheck.status === 'failed'
|
||||
) {
|
||||
const reasons: SchemaCheckFailureReason[] = [];
|
||||
|
||||
if (compositionCheck.status === 'failed') {
|
||||
|
|
@ -176,9 +189,17 @@ export class CompositeModel {
|
|||
}
|
||||
}
|
||||
|
||||
if (policyCheck.status === 'failed') {
|
||||
reasons.push({
|
||||
code: CheckFailureReasonCode.PolicyInfringement,
|
||||
errors: policyCheck.reason.errors ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
conclusion: SchemaCheckConclusion.Failure,
|
||||
reasons,
|
||||
warnings: policyCheck.reason?.warnings ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -186,6 +207,7 @@ export class CompositeModel {
|
|||
conclusion: SchemaCheckConclusion.Success,
|
||||
state: {
|
||||
initial,
|
||||
warnings: policyCheck.result?.warnings ?? [],
|
||||
changes: diffCheck.result?.changes ?? null,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
SingleSchema,
|
||||
} from 'packages/services/api/src/shared/entities';
|
||||
import { Change } from '@graphql-inspector/core';
|
||||
import type { CheckPolicyResponse } from '@hive/policy';
|
||||
|
||||
export const SchemaPublishConclusion = {
|
||||
/**
|
||||
|
|
@ -62,11 +63,21 @@ export const CheckFailureReasonCode = {
|
|||
MissingServiceName: 'MISSING_SERVICE_NAME',
|
||||
CompositionFailure: 'COMPOSITION_FAILURE',
|
||||
BreakingChanges: 'BREAKING_CHANGES',
|
||||
PolicyInfringement: 'POLICY_INFRINGEMENT',
|
||||
} as const;
|
||||
|
||||
export type CheckFailureReasonCode =
|
||||
(typeof CheckFailureReasonCode)[keyof typeof CheckFailureReasonCode];
|
||||
|
||||
export type CheckPolicyResultRecord = CheckPolicyResponse[number] | { message: string };
|
||||
export type SchemaCheckWarning = {
|
||||
message: string;
|
||||
source: string;
|
||||
|
||||
line?: number;
|
||||
column?: number;
|
||||
};
|
||||
|
||||
export type SchemaCheckFailureReason =
|
||||
| {
|
||||
code: (typeof CheckFailureReasonCode)['MissingServiceName'];
|
||||
|
|
@ -81,12 +92,19 @@ export type SchemaCheckFailureReason =
|
|||
code: (typeof CheckFailureReasonCode)['BreakingChanges'];
|
||||
breakingChanges: Array<Change>;
|
||||
changes: Array<Change>;
|
||||
}
|
||||
| {
|
||||
code: (typeof CheckFailureReasonCode)['PolicyInfringement'];
|
||||
errors: Array<{
|
||||
message: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type SchemaCheckSuccess = {
|
||||
conclusion: (typeof SchemaCheckConclusion)['Success'];
|
||||
state: {
|
||||
changes: Array<Change> | null;
|
||||
warnings: SchemaCheckWarning[] | null;
|
||||
initial: boolean;
|
||||
};
|
||||
};
|
||||
|
|
@ -94,6 +112,7 @@ export type SchemaCheckSuccess = {
|
|||
export type SchemaCheckFailure = {
|
||||
conclusion: (typeof SchemaCheckConclusion)['Failure'];
|
||||
reasons: SchemaCheckFailureReason[];
|
||||
warnings: SchemaCheckWarning[] | null;
|
||||
};
|
||||
|
||||
export type SchemaCheckResult = SchemaCheckFailure | SchemaCheckSuccess;
|
||||
|
|
@ -224,3 +243,11 @@ export function getReasonByCode<
|
|||
}
|
||||
|
||||
export const temp = 'temp';
|
||||
|
||||
export function formatPolicyMessage(record: CheckPolicyResultRecord): string {
|
||||
if ('ruleId' in record) {
|
||||
return `${record.message} (source: policy-${record.ruleId})`;
|
||||
}
|
||||
|
||||
return record.message;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ export class SingleLegacyModel {
|
|||
conclusion: SchemaCheckConclusion.Success,
|
||||
state: {
|
||||
changes: null,
|
||||
warnings: null,
|
||||
initial,
|
||||
},
|
||||
};
|
||||
|
|
@ -131,6 +132,7 @@ export class SingleLegacyModel {
|
|||
|
||||
return {
|
||||
conclusion: SchemaCheckConclusion.Failure,
|
||||
warnings: [],
|
||||
reasons,
|
||||
};
|
||||
}
|
||||
|
|
@ -139,6 +141,7 @@ export class SingleLegacyModel {
|
|||
conclusion: SchemaCheckConclusion.Success,
|
||||
state: {
|
||||
changes: diffCheck.result?.changes ?? null,
|
||||
warnings: null,
|
||||
initial,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -85,12 +85,13 @@ export class SingleModel {
|
|||
conclusion: SchemaCheckConclusion.Success,
|
||||
state: {
|
||||
changes: null,
|
||||
warnings: [],
|
||||
initial,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const [compositionCheck, diffCheck] = await Promise.all([
|
||||
const [compositionCheck, diffCheck, policyCheck] = await Promise.all([
|
||||
this.checks.composition({
|
||||
orchestrator: this.orchestrator,
|
||||
project,
|
||||
|
|
@ -105,9 +106,20 @@ export class SingleModel {
|
|||
version: compareToLatest ? latest : latestComposable,
|
||||
includeUrlChanges: false,
|
||||
}),
|
||||
this.checks.policyCheck({
|
||||
orchestrator: this.orchestrator,
|
||||
project,
|
||||
selector,
|
||||
schemas,
|
||||
modifiedSdl: input.sdl,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (compositionCheck.status === 'failed' || diffCheck.status === 'failed') {
|
||||
if (
|
||||
compositionCheck.status === 'failed' ||
|
||||
diffCheck.status === 'failed' ||
|
||||
policyCheck.status === 'failed'
|
||||
) {
|
||||
const reasons: SchemaCheckFailureReason[] = [];
|
||||
|
||||
if (compositionCheck.status === 'failed') {
|
||||
|
|
@ -136,8 +148,16 @@ export class SingleModel {
|
|||
}
|
||||
}
|
||||
|
||||
if (policyCheck.status === 'failed') {
|
||||
reasons.push({
|
||||
code: CheckFailureReasonCode.PolicyInfringement,
|
||||
errors: policyCheck.reason.errors ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
conclusion: SchemaCheckConclusion.Failure,
|
||||
warnings: policyCheck.reason?.warnings ?? [],
|
||||
reasons,
|
||||
};
|
||||
}
|
||||
|
|
@ -146,6 +166,7 @@ export class SingleModel {
|
|||
conclusion: SchemaCheckConclusion.Success,
|
||||
state: {
|
||||
changes: diffCheck.result?.changes ?? null,
|
||||
warnings: policyCheck.result?.warnings ?? [],
|
||||
initial,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { CriticalityLevel } from '@graphql-inspector/core';
|
|||
import type { CompositionFailureError } from '@hive/schema';
|
||||
import { Schema } from '../../../shared/entities';
|
||||
import { buildSchema } from '../../../shared/schema';
|
||||
import { SchemaPolicyProvider } from '../../policy/providers/schema-policy.provider';
|
||||
import {
|
||||
RegistryServiceUrlChangeSerializableChange,
|
||||
schemaChangeFromMeta,
|
||||
|
|
@ -17,6 +18,7 @@ import type {
|
|||
} from './../../../shared/entities';
|
||||
import { Logger } from './../../shared/providers/logger';
|
||||
import { Inspector } from './inspector';
|
||||
import { SchemaCheckWarning } from './models/shared';
|
||||
import { ensureSDL, extendWithBase, isCompositeSchema, SchemaHelper } from './schema-helper';
|
||||
|
||||
// The reason why I'm using `result` and `reason` instead of just `data` for both:
|
||||
|
|
@ -48,6 +50,13 @@ function isCompositionValidationError(error: CompositionFailureError): error is
|
|||
return error.source === 'composition';
|
||||
}
|
||||
|
||||
function isPolicyValidationError(error: CompositionFailureError): error is {
|
||||
message: string;
|
||||
source: 'policy';
|
||||
} {
|
||||
return error.source === 'policy';
|
||||
}
|
||||
|
||||
function isGraphQLValidationError(error: CompositionFailureError): error is {
|
||||
message: string;
|
||||
source: 'graphql';
|
||||
|
|
@ -59,7 +68,12 @@ function isGraphQLValidationError(error: CompositionFailureError): error is {
|
|||
scope: Scope.Operation,
|
||||
})
|
||||
export class RegistryChecks {
|
||||
constructor(private helper: SchemaHelper, private inspector: Inspector, private logger: Logger) {}
|
||||
constructor(
|
||||
private helper: SchemaHelper,
|
||||
private policy: SchemaPolicyProvider,
|
||||
private inspector: Inspector,
|
||||
private logger: Logger,
|
||||
) {}
|
||||
|
||||
async checksum({ schemas, latestVersion }: { schemas: Schemas; latestVersion: LatestVersion }) {
|
||||
this.logger.debug(
|
||||
|
|
@ -125,6 +139,7 @@ export class RegistryChecks {
|
|||
errorsBySource: {
|
||||
graphql: validationErrors.filter(isGraphQLValidationError),
|
||||
composition: validationErrors.filter(isCompositionValidationError),
|
||||
policy: validationErrors.filter(isPolicyValidationError),
|
||||
},
|
||||
},
|
||||
} satisfies CheckResult;
|
||||
|
|
@ -145,6 +160,72 @@ export class RegistryChecks {
|
|||
} satisfies CheckResult;
|
||||
}
|
||||
|
||||
async policyCheck({
|
||||
orchestrator,
|
||||
project,
|
||||
schemas,
|
||||
selector,
|
||||
modifiedSdl,
|
||||
}: {
|
||||
orchestrator: Orchestrator;
|
||||
schemas: [SingleSchema] | PushedCompositeSchema[];
|
||||
project: Project;
|
||||
modifiedSdl: string;
|
||||
selector: {
|
||||
organization: string;
|
||||
project: string;
|
||||
target: string;
|
||||
};
|
||||
}) {
|
||||
const sdl = await ensureSDL(
|
||||
orchestrator.composeAndValidate(
|
||||
schemas.map(s => this.helper.createSchemaObject(s)),
|
||||
project.externalComposition,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const policyResult = await this.policy.checkPolicy(sdl.raw, modifiedSdl, selector);
|
||||
const warnings =
|
||||
policyResult?.warnings?.map<SchemaCheckWarning>(record => ({
|
||||
message: record.message,
|
||||
source: record.ruleId ? `policy-${record.ruleId}` : 'policy',
|
||||
column: record.column,
|
||||
line: record.line,
|
||||
})) ?? [];
|
||||
|
||||
if (policyResult === null) {
|
||||
return {
|
||||
status: 'skipped',
|
||||
} satisfies CheckResult;
|
||||
}
|
||||
|
||||
if (policyResult.success) {
|
||||
return {
|
||||
status: 'completed',
|
||||
result: {
|
||||
warnings,
|
||||
},
|
||||
} satisfies CheckResult;
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'failed',
|
||||
reason: {
|
||||
errors: policyResult.errors,
|
||||
warnings,
|
||||
},
|
||||
} satisfies CheckResult;
|
||||
} catch (e) {
|
||||
return {
|
||||
status: 'failed',
|
||||
reason: {
|
||||
errors: [{ message: (e as Error).message }],
|
||||
},
|
||||
} satisfies CheckResult;
|
||||
}
|
||||
}
|
||||
|
||||
async diff({
|
||||
orchestrator,
|
||||
project,
|
||||
|
|
|
|||
|
|
@ -29,10 +29,12 @@ import { CompositeLegacyModel } from './models/composite-legacy';
|
|||
import {
|
||||
CheckFailureReasonCode,
|
||||
DeleteFailureReasonCode,
|
||||
formatPolicyMessage,
|
||||
getReasonByCode,
|
||||
PublishFailureReasonCode,
|
||||
SchemaCheckConclusion,
|
||||
SchemaCheckResult,
|
||||
SchemaCheckWarning,
|
||||
SchemaDeleteConclusion,
|
||||
SchemaPublishConclusion,
|
||||
SchemaPublishResult,
|
||||
|
|
@ -273,11 +275,13 @@ export class SchemaPublisher {
|
|||
sha: input.github.commit,
|
||||
conclusion: checkResult.conclusion,
|
||||
changes: checkResult.state.changes ?? null,
|
||||
warnings: checkResult.state.warnings,
|
||||
breakingChanges: null,
|
||||
compositionErrors: null,
|
||||
errors: null,
|
||||
});
|
||||
}
|
||||
|
||||
return this.githubCheck({
|
||||
project,
|
||||
target,
|
||||
|
|
@ -292,6 +296,7 @@ export class SchemaPublisher {
|
|||
compositionErrors:
|
||||
getReasonByCode(checkResult, CheckFailureReasonCode.CompositionFailure)
|
||||
?.compositionErrors ?? null,
|
||||
warnings: checkResult.warnings ?? [],
|
||||
errors: (
|
||||
[] as Array<{
|
||||
message: string;
|
||||
|
|
@ -304,6 +309,9 @@ export class SchemaPublisher {
|
|||
},
|
||||
]
|
||||
: [],
|
||||
getReasonByCode(checkResult, CheckFailureReasonCode.PolicyInfringement)?.errors.map(
|
||||
e => ({ message: formatPolicyMessage(e) }),
|
||||
) ?? [],
|
||||
),
|
||||
});
|
||||
}
|
||||
|
|
@ -313,6 +321,7 @@ export class SchemaPublisher {
|
|||
__typename: 'SchemaCheckSuccess',
|
||||
valid: true,
|
||||
changes: checkResult.state.changes ?? [],
|
||||
warnings: checkResult.state.warnings ?? [],
|
||||
initial: checkResult.state.initial,
|
||||
} as const;
|
||||
}
|
||||
|
|
@ -321,6 +330,7 @@ export class SchemaPublisher {
|
|||
__typename: 'SchemaCheckError',
|
||||
valid: false,
|
||||
changes: getReasonByCode(checkResult, CheckFailureReasonCode.BreakingChanges)?.changes ?? [],
|
||||
warnings: checkResult.warnings ?? [],
|
||||
errors: (
|
||||
[] as Array<{
|
||||
message: string;
|
||||
|
|
@ -334,6 +344,9 @@ export class SchemaPublisher {
|
|||
]
|
||||
: [],
|
||||
getReasonByCode(checkResult, CheckFailureReasonCode.BreakingChanges)?.breakingChanges ?? [],
|
||||
getReasonByCode(checkResult, CheckFailureReasonCode.PolicyInfringement)?.errors.map(e => ({
|
||||
message: formatPolicyMessage(e),
|
||||
})) ?? [],
|
||||
getReasonByCode(checkResult, CheckFailureReasonCode.CompositionFailure)
|
||||
?.compositionErrors ?? [],
|
||||
),
|
||||
|
|
@ -950,12 +963,14 @@ export class SchemaPublisher {
|
|||
breakingChanges,
|
||||
compositionErrors,
|
||||
errors,
|
||||
warnings,
|
||||
}: {
|
||||
project: Project;
|
||||
target: Target;
|
||||
serviceName: string | null;
|
||||
sha: string;
|
||||
conclusion: SchemaCheckConclusion;
|
||||
warnings: SchemaCheckWarning[] | null;
|
||||
changes: Array<Change> | null;
|
||||
breakingChanges: Array<Change> | null;
|
||||
compositionErrors: Array<{
|
||||
|
|
@ -992,6 +1007,7 @@ export class SchemaPublisher {
|
|||
title = `Detected ${total} error${total === 1 ? '' : 's'}`;
|
||||
summary = [
|
||||
errors ? this.errorsToMarkdown(errors) : null,
|
||||
warnings ? this.warningsToMarkdown(warnings) : null,
|
||||
compositionErrors ? this.errorsToMarkdown(compositionErrors) : null,
|
||||
breakingChanges ? this.errorsToMarkdown(breakingChanges) : null,
|
||||
changes ? this.changesToMarkdown(changes) : null,
|
||||
|
|
@ -1274,6 +1290,19 @@ export class SchemaPublisher {
|
|||
return ['', ...errors.map(error => `- ${bolderize(error.message)}`)].join('\n');
|
||||
}
|
||||
|
||||
private warningsToMarkdown(warnings: SchemaCheckWarning[]): string {
|
||||
return [
|
||||
'',
|
||||
...warnings.map(warning => {
|
||||
const details = [warning.source ? `source: ${warning.source}` : undefined]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
return `- ${bolderize(warning.message)}${details ? ` (${details})` : ''}`;
|
||||
}),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
private changesToMarkdown(changes: ReadonlyArray<Change>): string {
|
||||
const breakingChanges = changes.filter(filterChangesByLevel(CriticalityLevel.Breaking));
|
||||
const dangerousChanges = changes.filter(filterChangesByLevel(CriticalityLevel.Dangerous));
|
||||
|
|
|
|||
|
|
@ -850,6 +850,7 @@ export const resolvers: SchemaModule.Resolvers = {
|
|||
},
|
||||
SchemaChangeConnection: createConnection(),
|
||||
SchemaErrorConnection: createConnection(),
|
||||
SchemaWarningConnection: createConnection(),
|
||||
SchemaCheckSuccess: {
|
||||
__isTypeOf(obj) {
|
||||
return obj.valid;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { gql } from 'graphql-modules';
|
|||
export default gql`
|
||||
scalar DateTime
|
||||
scalar JSON
|
||||
scalar JSONSchemaObject
|
||||
scalar SafeInt
|
||||
|
||||
type Query {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Injectable } from 'graphql-modules';
|
||||
import { Change } from '@graphql-inspector/core';
|
||||
import type { PolicyConfigurationObject } from '@hive/policy';
|
||||
import type {
|
||||
AddAlertChannelInput,
|
||||
AddAlertInput,
|
||||
|
|
@ -21,6 +22,7 @@ import type {
|
|||
Schema,
|
||||
SchemaCompositionError,
|
||||
SchemaLog,
|
||||
SchemaPolicy,
|
||||
SchemaVersion,
|
||||
Target,
|
||||
TargetSettings,
|
||||
|
|
@ -480,6 +482,20 @@ export interface Storage {
|
|||
}>;
|
||||
}>
|
||||
>;
|
||||
|
||||
/** Schema Policies */
|
||||
setSchemaPolicyForOrganization(input: {
|
||||
organizationId: string;
|
||||
policy: PolicyConfigurationObject;
|
||||
allowOverrides: boolean;
|
||||
}): Promise<SchemaPolicy>;
|
||||
setSchemaPolicyForProject(input: {
|
||||
projectId: string;
|
||||
policy: PolicyConfigurationObject;
|
||||
}): Promise<SchemaPolicy>;
|
||||
findInheritedPolicies(selector: ProjectSelector): Promise<SchemaPolicy[]>;
|
||||
getSchemaPolicyForOrganization(organizationId: string): Promise<SchemaPolicy | null>;
|
||||
getSchemaPolicyForProject(projectId: string): Promise<SchemaPolicy | null>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ SafeIntResolver.description = undefined;
|
|||
export const resolvers: SharedModule.Resolvers = {
|
||||
DateTime: DateTimeResolver,
|
||||
JSON: JSONResolver,
|
||||
JSONSchemaObject: JSONResolver,
|
||||
SafeInt: SafeIntResolver,
|
||||
Query: {
|
||||
noop: () => true,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { DocumentNode, GraphQLError, SourceLocation } from 'graphql';
|
||||
import { parse } from 'graphql';
|
||||
import { z } from 'zod';
|
||||
import type { AvailableRulesResponse, PolicyConfigurationObject } from '@hive/policy';
|
||||
import type { CompositionFailureError } from '@hive/schema';
|
||||
import { schema_policy_resource } from '@hive/storage';
|
||||
import type {
|
||||
AlertChannelType,
|
||||
AlertType,
|
||||
|
|
@ -317,7 +319,19 @@ export interface AdminOrganizationStats {
|
|||
|
||||
export const SchemaCompositionErrorModel = z.object({
|
||||
message: z.string(),
|
||||
source: z.union([z.literal('graphql'), z.literal('composition')]),
|
||||
source: z.union([z.literal('graphql'), z.literal('composition'), z.literal('policy')]),
|
||||
});
|
||||
|
||||
export type SchemaCompositionError = z.TypeOf<typeof SchemaCompositionErrorModel>;
|
||||
|
||||
export type SchemaPolicy = {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
config: PolicyConfigurationObject;
|
||||
resource: schema_policy_resource;
|
||||
resourceId: string;
|
||||
allowOverrides: boolean;
|
||||
};
|
||||
|
||||
export type SchemaPolicyAvailableRuleObject = AvailableRulesResponse[0];
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import type {
|
|||
SchemaChange,
|
||||
SchemaError,
|
||||
} from '../__generated__/types';
|
||||
import { SchemaCheckWarning } from '../modules/schema/providers/models/shared';
|
||||
import { SchemaBuildError } from '../modules/schema/providers/orchestrators/errors';
|
||||
import { SerializableChange } from '../modules/schema/schema-change-from-meta';
|
||||
import type {
|
||||
|
|
@ -100,6 +101,7 @@ export type GraphQLScalarTypeMapper = WithSchemaCoordinatesUsage<{ entity: Graph
|
|||
|
||||
export type SchemaChangeConnection = ReadonlyArray<SchemaChange>;
|
||||
export type SchemaErrorConnection = readonly SchemaError[];
|
||||
export type SchemaWarningConnection = readonly SchemaCheckWarning[];
|
||||
export type UserConnection = readonly User[];
|
||||
export type MemberConnection = readonly Member[];
|
||||
export type ActivityConnection = readonly ActivityObject[];
|
||||
|
|
|
|||
21
packages/services/policy/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2022 The Guild
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
4
packages/services/policy/README.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# `@hive/policy`
|
||||
|
||||
Service for running [GraphQL-ESLint](https://github.com/B2o5T/graphql-eslint) as part of Hive schema
|
||||
policy checks.
|
||||
57
packages/services/policy/__tests__/policy.spec.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { schemaPolicyApiRouter } from '../src/api';
|
||||
|
||||
describe('policy checks', () => {
|
||||
it('should return empty result where there are not lint rules', async () => {
|
||||
const result = await schemaPolicyApiRouter
|
||||
.createCaller({ req: { log: console } as any })
|
||||
.checkPolicy({
|
||||
source: 'type Query { foo: String }',
|
||||
schema: 'type Query { foo: String }',
|
||||
target: '1',
|
||||
policy: {},
|
||||
});
|
||||
|
||||
expect(result.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should return warnings correctly', async () => {
|
||||
const result = await schemaPolicyApiRouter
|
||||
.createCaller({ req: { log: console } as any })
|
||||
.checkPolicy({
|
||||
source: 'type Query { foo: String }',
|
||||
schema: 'type Query { foo: String }',
|
||||
target: '1',
|
||||
policy: {
|
||||
'require-description': ['warn', { types: true, FieldDefinition: true }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
column: 6,
|
||||
endColumn: 11,
|
||||
endLine: 1,
|
||||
line: 1,
|
||||
message: Description is required for type "Query",
|
||||
messageId: require-description,
|
||||
nodeType: null,
|
||||
ruleId: require-description,
|
||||
severity: 1,
|
||||
},
|
||||
{
|
||||
column: 14,
|
||||
endColumn: 17,
|
||||
endLine: 1,
|
||||
line: 1,
|
||||
message: Description is required for field "foo" in type "Query",
|
||||
messageId: require-description,
|
||||
nodeType: null,
|
||||
ruleId: require-description,
|
||||
severity: 1,
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
38
packages/services/policy/package.json
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"name": "@hive/policy",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "bob runify --single",
|
||||
"dev": "tsup-node --config ../../../configs/tsup/dev.config.node.ts src/dev.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@graphql-eslint/eslint-plugin": "3.18.0",
|
||||
"@sentry/node": "7.49.0",
|
||||
"@sentry/tracing": "7.49.0",
|
||||
"@trpc/server": "10.21.1",
|
||||
"@whatwg-node/fetch": "0.8.8",
|
||||
"ajv": "8.12.0",
|
||||
"dotenv": "16.0.3",
|
||||
"eslint": "8.40.0",
|
||||
"graphql": "16.6.0",
|
||||
"zod": "3.21.4",
|
||||
"zod-validation-error": "1.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hive/service-common": "workspace:*",
|
||||
"fastify": "3.29.5",
|
||||
"pino-pretty": "10.0.0"
|
||||
},
|
||||
"buildOptions": {
|
||||
"runify": true,
|
||||
"tsup": true,
|
||||
"tags": [
|
||||
"backend"
|
||||
],
|
||||
"banner": "../../../scripts/banner.js"
|
||||
}
|
||||
}
|
||||
104
packages/services/policy/src/api.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import type { FastifyRequest } from 'fastify';
|
||||
import { z, ZodError } from 'zod';
|
||||
import { fromZodError } from 'zod-validation-error';
|
||||
import { handleTRPCError } from '@hive/service-common';
|
||||
import { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
|
||||
import { initTRPC } from '@trpc/server';
|
||||
import { policyCheckCounter, policyCheckDuration } from './metrics';
|
||||
import { createInputValidationSchema, normalizeAjvSchema, schemaPolicyCheck } from './policy';
|
||||
import { RELEVANT_RULES } from './rules';
|
||||
|
||||
export type { PolicyConfigurationObject } from './policy';
|
||||
|
||||
export interface Context {
|
||||
req: FastifyRequest;
|
||||
}
|
||||
|
||||
const t = initTRPC.context<Context>().create({
|
||||
errorFormatter({ shape, error }) {
|
||||
return {
|
||||
...shape,
|
||||
data: {
|
||||
...shape.data,
|
||||
zodError:
|
||||
error.code === 'BAD_REQUEST' && error.cause instanceof ZodError
|
||||
? error.cause.flatten()
|
||||
: null,
|
||||
formatted:
|
||||
error.code === 'BAD_REQUEST' && error.cause instanceof ZodError
|
||||
? fromZodError(error.cause).message
|
||||
: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
const errorMiddleware = t.middleware(handleTRPCError);
|
||||
const procedure = t.procedure.use(errorMiddleware);
|
||||
|
||||
const CONFIG_VALIDATION_SCHEMA = createInputValidationSchema();
|
||||
const CONFIG_CHECK_INPUT_VALIDATION = z
|
||||
.object({
|
||||
config: CONFIG_VALIDATION_SCHEMA,
|
||||
})
|
||||
.required();
|
||||
const POLICY_CHECK_INPUT_VALIDATION = z
|
||||
.object({
|
||||
/**
|
||||
* Target ID, used mainly for logging purposes
|
||||
*/
|
||||
target: z.string(),
|
||||
/**
|
||||
* Represents the part of the schema that was changed (e.g. a service)
|
||||
*/
|
||||
source: z.string().min(1),
|
||||
/**
|
||||
* Represents the complete schema, not just the changed service
|
||||
*/
|
||||
schema: z.string().min(1),
|
||||
/**
|
||||
* The ESLint policy config JSON
|
||||
*/
|
||||
policy: CONFIG_VALIDATION_SCHEMA,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const schemaPolicyApiRouter = t.router({
|
||||
availableRules: procedure.query(() => {
|
||||
return RELEVANT_RULES.map(([name, rule]) => ({
|
||||
name,
|
||||
description: rule.meta.docs?.description || '',
|
||||
recommended: rule.meta.docs?.recommended ?? false,
|
||||
url: rule.meta.docs?.url,
|
||||
schema: normalizeAjvSchema(rule.meta.schema) as object | null,
|
||||
}));
|
||||
}),
|
||||
validateConfig: procedure.input(CONFIG_CHECK_INPUT_VALIDATION).query(() => {
|
||||
// Zod is doing the validation, so we just need to return true if case it's valid
|
||||
return true;
|
||||
}),
|
||||
checkPolicy: procedure.input(POLICY_CHECK_INPUT_VALIDATION).mutation(async ({ input, ctx }) => {
|
||||
policyCheckCounter.inc({ target: input.target });
|
||||
ctx.req.log.info(`Policy execution started, input is: %o`, input);
|
||||
|
||||
const stopTimer = policyCheckDuration.startTimer({ target: input.target });
|
||||
const result = await schemaPolicyCheck({
|
||||
source: input.source,
|
||||
schema: input.schema,
|
||||
policy: input.policy,
|
||||
});
|
||||
stopTimer();
|
||||
|
||||
ctx.req.log.info(`Policy execution was done for target ${input.target}, result is: %o`, {
|
||||
result,
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
|
||||
export type SchemaPolicyApi = typeof schemaPolicyApiRouter;
|
||||
export type SchemaPolicyApiInput = inferRouterInputs<SchemaPolicyApi>;
|
||||
|
||||
type RouterOutput = inferRouterOutputs<SchemaPolicyApi>;
|
||||
export type AvailableRulesResponse = RouterOutput['availableRules'];
|
||||
export type CheckPolicyResponse = RouterOutput['checkPolicy'];
|
||||
7
packages/services/policy/src/dev.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { config } from 'dotenv';
|
||||
|
||||
config({
|
||||
debug: true,
|
||||
});
|
||||
|
||||
await import('./index');
|
||||
116
packages/services/policy/src/environment.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import zod from 'zod';
|
||||
|
||||
const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success;
|
||||
|
||||
const numberFromNumberOrNumberString = (input: unknown): number | undefined => {
|
||||
if (typeof input == 'number') return input;
|
||||
if (isNumberString(input)) return Number(input);
|
||||
};
|
||||
|
||||
const NumberFromString = zod.preprocess(numberFromNumberOrNumberString, zod.number().min(1));
|
||||
|
||||
// treat an empty string (`''`) as undefined
|
||||
const emptyString = <T extends zod.ZodType>(input: T) => {
|
||||
return zod.preprocess((value: unknown) => {
|
||||
if (value === '') return undefined;
|
||||
return value;
|
||||
}, input);
|
||||
};
|
||||
|
||||
const EnvironmentModel = zod.object({
|
||||
PORT: emptyString(NumberFromString.optional()),
|
||||
ENVIRONMENT: emptyString(zod.string().optional()),
|
||||
RELEASE: emptyString(zod.string().optional()),
|
||||
});
|
||||
|
||||
const SentryModel = zod.union([
|
||||
zod.object({
|
||||
SENTRY: emptyString(zod.literal('0').optional()),
|
||||
}),
|
||||
zod.object({
|
||||
SENTRY: zod.literal('1'),
|
||||
SENTRY_DSN: zod.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const PrometheusModel = zod.object({
|
||||
PROMETHEUS_METRICS: emptyString(zod.union([zod.literal('0'), zod.literal('1')]).optional()),
|
||||
PROMETHEUS_METRICS_LABEL_INSTANCE: emptyString(zod.string().optional()),
|
||||
});
|
||||
|
||||
const LogModel = zod.object({
|
||||
LOG_LEVEL: emptyString(
|
||||
zod
|
||||
.union([
|
||||
zod.literal('trace'),
|
||||
zod.literal('debug'),
|
||||
zod.literal('info'),
|
||||
zod.literal('warn'),
|
||||
zod.literal('error'),
|
||||
zod.literal('fatal'),
|
||||
zod.literal('silent'),
|
||||
])
|
||||
.optional(),
|
||||
),
|
||||
REQUEST_LOGGING: emptyString(zod.union([zod.literal('0'), zod.literal('1')]).optional()).default(
|
||||
'1',
|
||||
),
|
||||
});
|
||||
|
||||
const configs = {
|
||||
// eslint-disable-next-line no-process-env
|
||||
base: EnvironmentModel.safeParse(process.env),
|
||||
// eslint-disable-next-line no-process-env
|
||||
sentry: SentryModel.safeParse(process.env),
|
||||
// eslint-disable-next-line no-process-env
|
||||
prometheus: PrometheusModel.safeParse(process.env),
|
||||
// eslint-disable-next-line no-process-env
|
||||
log: LogModel.safeParse(process.env),
|
||||
};
|
||||
|
||||
const environmentErrors: Array<string> = [];
|
||||
|
||||
for (const config of Object.values(configs)) {
|
||||
if (config.success === false) {
|
||||
environmentErrors.push(JSON.stringify(config.error.format(), null, 4));
|
||||
}
|
||||
}
|
||||
|
||||
if (environmentErrors.length) {
|
||||
const fullError = environmentErrors.join(`\n`);
|
||||
console.error('❌ Invalid environment variables:', fullError);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function extractConfig<Input, Output>(config: zod.SafeParseReturnType<Input, Output>): Output {
|
||||
if (!config.success) {
|
||||
throw new Error('Something went wrong.');
|
||||
}
|
||||
return config.data;
|
||||
}
|
||||
|
||||
const base = extractConfig(configs.base);
|
||||
const sentry = extractConfig(configs.sentry);
|
||||
const prometheus = extractConfig(configs.prometheus);
|
||||
const log = extractConfig(configs.log);
|
||||
|
||||
export const env = {
|
||||
environment: base.ENVIRONMENT,
|
||||
release: base.RELEASE ?? 'local',
|
||||
http: {
|
||||
port: base.PORT ?? 6600,
|
||||
},
|
||||
sentry: sentry.SENTRY === '1' ? { dsn: sentry.SENTRY_DSN } : null,
|
||||
log: {
|
||||
level: log.LOG_LEVEL ?? 'info',
|
||||
requests: log.REQUEST_LOGGING === '1',
|
||||
},
|
||||
prometheus:
|
||||
prometheus.PROMETHEUS_METRICS === '1'
|
||||
? {
|
||||
labels: {
|
||||
instance: prometheus.PROMETHEUS_METRICS_LABEL_INSTANCE ?? 'schema',
|
||||
},
|
||||
}
|
||||
: null,
|
||||
} as const;
|
||||
81
packages/services/policy/src/index.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
#!/usr/bin/env node
|
||||
import {
|
||||
createServer,
|
||||
registerShutdown,
|
||||
registerTRPC,
|
||||
reportReadiness,
|
||||
startMetrics,
|
||||
} from '@hive/service-common';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { Context, schemaPolicyApiRouter } from './api';
|
||||
import { env } from './environment';
|
||||
|
||||
async function main() {
|
||||
if (env.sentry) {
|
||||
Sentry.init({
|
||||
serverName: 'schema',
|
||||
enabled: !!env.sentry,
|
||||
environment: env.environment,
|
||||
dsn: env.sentry.dsn,
|
||||
release: env.release,
|
||||
});
|
||||
}
|
||||
|
||||
const server = await createServer({
|
||||
name: 'policy',
|
||||
tracing: false,
|
||||
log: {
|
||||
level: env.log.level,
|
||||
requests: env.log.requests,
|
||||
},
|
||||
});
|
||||
|
||||
registerShutdown({
|
||||
logger: server.log,
|
||||
async onShutdown() {
|
||||
await server.close();
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await registerTRPC(server, {
|
||||
router: schemaPolicyApiRouter,
|
||||
createContext({ req }): Context {
|
||||
return { req };
|
||||
},
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: ['GET', 'HEAD'],
|
||||
url: '/_health',
|
||||
handler(_, res) {
|
||||
void res.status(200).send();
|
||||
},
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: ['GET', 'HEAD'],
|
||||
url: '/_readiness',
|
||||
handler(_, res) {
|
||||
reportReadiness(true);
|
||||
void res.status(200).send();
|
||||
},
|
||||
});
|
||||
|
||||
await server.listen(env.http.port, '::');
|
||||
if (env.prometheus) {
|
||||
await startMetrics(env.prometheus.labels.instance);
|
||||
}
|
||||
} catch (error) {
|
||||
server.log.fatal(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
Sentry.captureException(err, {
|
||||
level: 'fatal',
|
||||
});
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
13
packages/services/policy/src/metrics.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { metrics } from '@hive/service-common';
|
||||
|
||||
export const policyCheckCounter = new metrics.Counter({
|
||||
name: 'policy_check_total',
|
||||
help: 'Number of calls to policy check service',
|
||||
labelNames: ['target'],
|
||||
});
|
||||
|
||||
export const policyCheckDuration = new metrics.Histogram({
|
||||
name: 'schema_policy_check_duration',
|
||||
help: 'Duration of schema policy check',
|
||||
labelNames: ['target'],
|
||||
});
|
||||
103
packages/services/policy/src/policy.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import Ajv from 'ajv';
|
||||
import { Linter } from 'eslint';
|
||||
import { z, ZodType } from 'zod';
|
||||
import { GraphQLESLintRule, parseForESLint, rules } from '@graphql-eslint/eslint-plugin';
|
||||
import { RELEVANT_RULES } from './rules';
|
||||
|
||||
const ajv = new Ajv({
|
||||
meta: false,
|
||||
useDefaults: true,
|
||||
validateSchema: false,
|
||||
verbose: true,
|
||||
allowMatchingProperties: true,
|
||||
});
|
||||
const linter = new Linter();
|
||||
linter.defineParser('@graphql-eslint/eslint-plugin', { parseForESLint });
|
||||
|
||||
for (const [ruleId, rule] of Object.entries(rules)) {
|
||||
linter.defineRule(ruleId, rule as any);
|
||||
}
|
||||
|
||||
const RULE_LEVEL = z.union([z.number().min(0).max(2), z.enum(['off', 'warn', 'error'])]);
|
||||
|
||||
type RulemapValidationType = {
|
||||
[RuleKey in keyof typeof rules]: ZodType;
|
||||
};
|
||||
|
||||
export function normalizeAjvSchema(
|
||||
schema: GraphQLESLintRule['meta']['schema'],
|
||||
): GraphQLESLintRule['meta']['schema'] {
|
||||
if (Array.isArray(schema)) {
|
||||
if (schema.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'array',
|
||||
items: schema,
|
||||
minItems: 0,
|
||||
maxItems: schema.length,
|
||||
};
|
||||
}
|
||||
|
||||
return schema || null;
|
||||
}
|
||||
|
||||
export function createInputValidationSchema() {
|
||||
return z
|
||||
.object(
|
||||
RELEVANT_RULES.reduce((acc, [name, rule]) => {
|
||||
const schema = normalizeAjvSchema(rule.meta.schema);
|
||||
const validate = schema ? ajv.compile(schema) : null;
|
||||
|
||||
return {
|
||||
...acc,
|
||||
[name]: z.union([
|
||||
z.tuple([RULE_LEVEL]),
|
||||
z.tuple(
|
||||
validate
|
||||
? [
|
||||
RULE_LEVEL,
|
||||
z.custom(data => {
|
||||
const asArray = (Array.isArray(data) ? data : [data]).filter(Boolean);
|
||||
const result = validate(asArray);
|
||||
|
||||
if (result) {
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to validate rule "${name}" configuration: ${ajv.errorsText(
|
||||
validate.errors,
|
||||
)}`,
|
||||
);
|
||||
}),
|
||||
]
|
||||
: [RULE_LEVEL],
|
||||
),
|
||||
]),
|
||||
};
|
||||
}, {} as RulemapValidationType),
|
||||
)
|
||||
.required()
|
||||
.partial()
|
||||
.strict('Unkonwn rule name passed');
|
||||
}
|
||||
|
||||
export type PolicyConfigurationObject = z.infer<ReturnType<typeof createInputValidationSchema>>;
|
||||
|
||||
export async function schemaPolicyCheck(input: {
|
||||
source: string;
|
||||
schema: string;
|
||||
policy: PolicyConfigurationObject;
|
||||
}) {
|
||||
return linter.verify(
|
||||
input.source,
|
||||
{
|
||||
parser: '@graphql-eslint/eslint-plugin',
|
||||
parserOptions: { schema: input.schema },
|
||||
rules: input.policy,
|
||||
},
|
||||
'schema.graphql',
|
||||
);
|
||||
}
|
||||
78
packages/services/policy/src/rules.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { type CategoryType, GraphQLESLintRule, rules } from '@graphql-eslint/eslint-plugin';
|
||||
|
||||
type AllRulesType = typeof rules;
|
||||
type RuleName = keyof AllRulesType;
|
||||
|
||||
const SKIPPED_RULES: RuleName[] = [
|
||||
// Skipped because in order to operate, it needs operations.
|
||||
// Also it does not make sense to run it as part of a schema check.
|
||||
'no-unused-fields',
|
||||
];
|
||||
|
||||
function isRelevantCategory(category?: CategoryType | CategoryType[]): boolean {
|
||||
if (!category) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Array.isArray(category) ? category.includes('Schema') : category === 'Schema';
|
||||
}
|
||||
|
||||
// Some rules have configurations for operations (like "alphabetize") and we do not want to expose them.
|
||||
function patchRulesConfig<T extends RuleName>(
|
||||
ruleName: T,
|
||||
ruleDef: AllRulesType[T],
|
||||
): GraphQLESLintRule {
|
||||
switch (ruleName) {
|
||||
case 'alphabetize': {
|
||||
// Remove operation-specific configurations
|
||||
delete ruleDef.meta.schema.items.properties.selections;
|
||||
delete ruleDef.meta.schema.items.properties.variables;
|
||||
break;
|
||||
}
|
||||
case 'naming-convention': {
|
||||
// Remove operation-specific configurations
|
||||
delete ruleDef.meta.schema.items.properties.VariableDefinition;
|
||||
delete ruleDef.meta.schema.items.properties.OperationDefinition;
|
||||
|
||||
// Get rid of "definitions" references becuse it's breaking Monaco editor in the frontend
|
||||
Object.entries(ruleDef.meta.schema.items.properties).forEach(([, propDef]) => {
|
||||
if (propDef && typeof propDef === 'object' && 'oneOf' in propDef) {
|
||||
propDef.oneOf = [
|
||||
ruleDef.meta.schema.definitions.asObject,
|
||||
ruleDef.meta.schema.definitions.asString,
|
||||
];
|
||||
}
|
||||
});
|
||||
ruleDef.meta.schema.items.patternProperties = {
|
||||
'^(Argument|DirectiveDefinition|EnumTypeDefinition|EnumValueDefinition|FieldDefinition|InputObjectTypeDefinition|InputValueDefinition|InterfaceTypeDefinition|ObjectTypeDefinition|ScalarTypeDefinition|UnionTypeDefinition)(.+)?$':
|
||||
{
|
||||
oneOf: [
|
||||
ruleDef.meta.schema.definitions.asObject,
|
||||
ruleDef.meta.schema.definitions.asString,
|
||||
],
|
||||
},
|
||||
};
|
||||
delete ruleDef.meta.schema.definitions;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return ruleDef as GraphQLESLintRule;
|
||||
}
|
||||
|
||||
// We are using only rules that are running on the schema (SDL) and not on operations.
|
||||
// Also, we do not need to run GraphQL validation rules because they are already running as part of Hive
|
||||
// Schema checks.
|
||||
// Some rule are mixed (like "alphabetize") so we are patch and "hiding" some of their configurations.
|
||||
export const RELEVANT_RULES = Object.entries(rules)
|
||||
.filter(
|
||||
([ruleName, rule]) =>
|
||||
isRelevantCategory(rule.meta.docs?.category) &&
|
||||
rule.meta.docs?.graphQLJSRuleName === undefined &&
|
||||
!SKIPPED_RULES.includes(ruleName as RuleName),
|
||||
)
|
||||
.map(
|
||||
([ruleName, rule]) =>
|
||||
[ruleName, patchRulesConfig(ruleName as RuleName, rule)] as [RuleName, GraphQLESLintRule],
|
||||
);
|
||||
9
packages/services/policy/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "esnext",
|
||||
"rootDir": "../.."
|
||||
},
|
||||
"files": ["src/index.ts"]
|
||||
}
|
||||
|
|
@ -47,7 +47,7 @@ interface CompositionSuccess {
|
|||
};
|
||||
}
|
||||
|
||||
export type CompositionErrorSource = 'graphql' | 'composition';
|
||||
export type CompositionErrorSource = 'graphql' | 'composition' | 'policy';
|
||||
|
||||
export interface CompositionFailureError {
|
||||
message: string;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ CLICKHOUSE_USERNAME="test"
|
|||
CLICKHOUSE_PASSWORD="test"
|
||||
TOKENS_ENDPOINT="http://localhost:6001"
|
||||
SCHEMA_ENDPOINT="http://localhost:6500"
|
||||
SCHEMA_POLICY_ENDPOINT="http://localhost:6600"
|
||||
USAGE_ESTIMATOR_ENDPOINT="http://localhost:4011"
|
||||
RATE_LIMIT_ENDPOINT="http://localhost:4012"
|
||||
BILLING_ENDPOINT="http://localhost:4013"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ The GraphQL API for GraphQL Hive.
|
|||
| `TOKENS_ENDPOINT` | **Yes** | The endpoint of the tokens service. | `http://127.0.0.1:6001` |
|
||||
| `WEBHOOKS_ENDPOINT` | **Yes** | The endpoint of the webhooks service. | `http://127.0.0.1:6250` |
|
||||
| `SCHEMA_ENDPOINT` | **Yes** | The endpoint of the schema service. | `http://127.0.0.1:6500` |
|
||||
| `SCHEMA_POLICY_ENDPOINT` | **No** | The endpoint of the schema policy service. | `http://127.0.0.1:6600` |
|
||||
| `POSTGRES_SSL` | No | Whether the postgres connection should be established via SSL. | `1` (enabled) or `0` (disabled) |
|
||||
| `POSTGRES_HOST` | **Yes** | Host of the postgres database | `127.0.0.1` |
|
||||
| `POSTGRES_PORT` | **Yes** | Port of the postgres database | `5432` |
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ const EnvironmentModel = zod.object({
|
|||
ENCRYPTION_SECRET: emptyString(zod.string()),
|
||||
WEB_APP_URL: emptyString(zod.string().url().optional()),
|
||||
RATE_LIMIT_ENDPOINT: emptyString(zod.string().url().optional()),
|
||||
SCHEMA_POLICY_ENDPOINT: emptyString(zod.string().url().optional()),
|
||||
TOKENS_ENDPOINT: zod.string().url(),
|
||||
USAGE_ESTIMATOR_ENDPOINT: emptyString(zod.string().url().optional()),
|
||||
BILLING_ENDPOINT: emptyString(zod.string().url().optional()),
|
||||
|
|
@ -257,6 +258,11 @@ export const env = {
|
|||
endpoint: base.RATE_LIMIT_ENDPOINT,
|
||||
}
|
||||
: null,
|
||||
schemaPolicy: base.SCHEMA_POLICY_ENDPOINT
|
||||
? {
|
||||
endpoint: base.SCHEMA_POLICY_ENDPOINT,
|
||||
}
|
||||
: null,
|
||||
usageEstimator: base.USAGE_ESTIMATOR_ENDPOINT
|
||||
? { endpoint: base.USAGE_ESTIMATOR_ENDPOINT }
|
||||
: null,
|
||||
|
|
|
|||
|
|
@ -188,6 +188,9 @@ export async function main() {
|
|||
rateLimitService: {
|
||||
endpoint: env.hiveServices.rateLimit ? env.hiveServices.rateLimit.endpoint : null,
|
||||
},
|
||||
schemaPolicyService: {
|
||||
endpoint: env.hiveServices.schemaPolicy ? env.hiveServices.schemaPolicy.endpoint : null,
|
||||
},
|
||||
logger: graphqlLogger,
|
||||
storage,
|
||||
redis: {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
export type alert_channel_type = "SLACK" | "WEBHOOK";
|
||||
export type alert_type = "SCHEMA_CHANGE_NOTIFICATIONS";
|
||||
export type operation_kind = "mutation" | "query" | "subscription";
|
||||
export type schema_policy_resource = "ORGANIZATION" | "PROJECT";
|
||||
export type user_role = "ADMIN" | "MEMBER";
|
||||
|
||||
export interface activities {
|
||||
|
|
@ -155,6 +156,15 @@ export interface schema_log {
|
|||
target_id: string;
|
||||
}
|
||||
|
||||
export interface schema_policy_config {
|
||||
allow_overriding: boolean;
|
||||
config: any;
|
||||
created_at: Date;
|
||||
resource_id: string;
|
||||
resource_type: schema_policy_resource;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface schema_version_changes {
|
||||
change_type: string;
|
||||
id: string;
|
||||
|
|
@ -255,6 +265,7 @@ export interface DBTables {
|
|||
persisted_operations: persisted_operations;
|
||||
projects: projects;
|
||||
schema_log: schema_log;
|
||||
schema_policy_config: schema_policy_config;
|
||||
schema_version_changes: schema_version_changes;
|
||||
schema_version_to_log: schema_version_to_log;
|
||||
schema_versions: schema_versions;
|
||||
|
|
|
|||
|
|
@ -31,10 +31,11 @@ import type {
|
|||
import { batch } from '@theguild/buddy';
|
||||
import { ProjectType } from '../../api/src';
|
||||
import {
|
||||
CDNAccessToken,
|
||||
OIDCIntegration,
|
||||
type CDNAccessToken,
|
||||
type OIDCIntegration,
|
||||
SchemaCompositionErrorModel,
|
||||
SchemaLog,
|
||||
type SchemaLog,
|
||||
type SchemaPolicy,
|
||||
} from '../../api/src/shared/entities';
|
||||
import {
|
||||
activities,
|
||||
|
|
@ -49,6 +50,7 @@ import {
|
|||
persisted_operations,
|
||||
projects,
|
||||
schema_log as schema_log_in_db,
|
||||
schema_policy_config,
|
||||
schema_versions,
|
||||
target_validation,
|
||||
targets,
|
||||
|
|
@ -61,7 +63,7 @@ import type { Slonik } from './shared';
|
|||
export { ConnectionError } from 'slonik';
|
||||
export { createConnectionString } from './db/utils';
|
||||
export { createTokenStorage } from './tokens';
|
||||
export type { tokens } from './db/types';
|
||||
export type { tokens, schema_policy_resource } from './db/types';
|
||||
|
||||
type Connection = DatabasePool | DatabaseTransactionConnection;
|
||||
|
||||
|
|
@ -120,6 +122,18 @@ export async function createStorage(connection: string, maximumPoolSize: number)
|
|||
};
|
||||
}
|
||||
|
||||
function transformSchemaPolicy(schema_policy: schema_policy_config): SchemaPolicy {
|
||||
return {
|
||||
id: `${schema_policy.resource_type}_${schema_policy.resource_id}`,
|
||||
config: schema_policy.config,
|
||||
createdAt: schema_policy.created_at,
|
||||
updatedAt: schema_policy.updated_at,
|
||||
resource: schema_policy.resource_type,
|
||||
resourceId: schema_policy.resource_id,
|
||||
allowOverrides: schema_policy.allow_overriding,
|
||||
};
|
||||
}
|
||||
|
||||
function transformMember(
|
||||
user: users & Pick<organization_member, 'scopes' | 'organization_id'> & { is_owner: boolean },
|
||||
): Member {
|
||||
|
|
@ -2990,6 +3004,78 @@ export async function createStorage(connection: string, maximumPoolSize: number)
|
|||
},
|
||||
};
|
||||
},
|
||||
|
||||
async setSchemaPolicyForOrganization(input): Promise<SchemaPolicy> {
|
||||
const result = await pool.one<schema_policy_config>(sql`
|
||||
INSERT INTO "public"."schema_policy_config"
|
||||
("resource_type", "resource_id", "config", "allow_overriding")
|
||||
VALUES ('ORGANIZATION', ${input.organizationId}, ${sql.jsonb(input.policy)}, ${
|
||||
input.allowOverrides
|
||||
})
|
||||
ON CONFLICT
|
||||
(resource_type, resource_id)
|
||||
DO UPDATE
|
||||
SET "config" = ${sql.jsonb(input.policy)},
|
||||
"allow_overriding" = ${input.allowOverrides},
|
||||
"updated_at" = now()
|
||||
RETURNING *;
|
||||
`);
|
||||
|
||||
return transformSchemaPolicy(result);
|
||||
},
|
||||
async setSchemaPolicyForProject(input): Promise<SchemaPolicy> {
|
||||
const result = await pool.one<schema_policy_config>(sql`
|
||||
INSERT INTO "public"."schema_policy_config"
|
||||
("resource_type", "resource_id", "config")
|
||||
VALUES ('PROJECT', ${input.projectId}, ${sql.jsonb(input.policy)})
|
||||
ON CONFLICT
|
||||
(resource_type, resource_id)
|
||||
DO UPDATE
|
||||
SET "config" = ${sql.jsonb(input.policy)},
|
||||
"updated_at" = now()
|
||||
RETURNING *;
|
||||
`);
|
||||
|
||||
return transformSchemaPolicy(result);
|
||||
},
|
||||
async findInheritedPolicies(selector): Promise<SchemaPolicy[]> {
|
||||
const { organization, project } = selector;
|
||||
|
||||
const result = await pool.any<schema_policy_config>(sql`
|
||||
SELECT *
|
||||
FROM
|
||||
"public"."schema_policy_config"
|
||||
WHERE
|
||||
("resource_type" = 'ORGANIZATION' AND "resource_id" = ${organization})
|
||||
OR ("resource_type" = 'PROJECT' AND "resource_id" = ${project});
|
||||
`);
|
||||
|
||||
return result.map(transformSchemaPolicy);
|
||||
},
|
||||
async getSchemaPolicyForOrganization(organizationId: string): Promise<SchemaPolicy | null> {
|
||||
const result = await pool.maybeOne<schema_policy_config>(sql`
|
||||
SELECT *
|
||||
FROM
|
||||
"public"."schema_policy_config"
|
||||
WHERE
|
||||
"resource_type" = 'ORGANIZATION'
|
||||
AND "resource_id" = ${organizationId};
|
||||
`);
|
||||
|
||||
return result ? transformSchemaPolicy(result) : null;
|
||||
},
|
||||
async getSchemaPolicyForProject(projectId: string): Promise<SchemaPolicy | null> {
|
||||
const result = await pool.maybeOne<schema_policy_config>(sql`
|
||||
SELECT *
|
||||
FROM
|
||||
"public"."schema_policy_config"
|
||||
WHERE
|
||||
"resource_type" = 'PROJECT'
|
||||
AND "resource_id" = ${projectId};
|
||||
`);
|
||||
|
||||
return result ? transformSchemaPolicy(result) : null;
|
||||
},
|
||||
};
|
||||
|
||||
return storage;
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@
|
|||
"hyperid": "3.1.1",
|
||||
"immer": "10.0.1",
|
||||
"js-cookie": "3.0.5",
|
||||
"json-schema-typed": "8.0.1",
|
||||
"json-schema-yup-transformer": "1.6.12",
|
||||
"monaco-editor": "0.37.1",
|
||||
"monaco-themes": "0.4.4",
|
||||
"next": "13.3.1",
|
||||
|
|
|
|||
120
packages/web/app/pages/[orgId]/[projectId]/view/policy.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { ReactElement } from 'react';
|
||||
import { useMutation } from 'urql';
|
||||
import { authenticated } from '@/components/authenticated-container';
|
||||
import { ProjectLayout } from '@/components/layouts';
|
||||
import { PolicySettings } from '@/components/policy/policy-settings';
|
||||
import { Card, DocsLink, DocsNote, Heading, Title } from '@/components/v2';
|
||||
import { graphql } from '@/gql';
|
||||
import { withSessionProtection } from '@/lib/supertokens/guard';
|
||||
|
||||
const ProjectPolicyPageQuery = graphql(`
|
||||
query ProjectPolicyPageQuery($organizationId: ID!, $projectId: ID!) {
|
||||
organization(selector: { organization: $organizationId }) {
|
||||
organization {
|
||||
id
|
||||
schemaPolicy {
|
||||
id
|
||||
updatedAt
|
||||
allowOverrides
|
||||
rules {
|
||||
rule {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
...ProjectLayout_OrganizationFragment
|
||||
}
|
||||
}
|
||||
project(selector: { organization: $organizationId, project: $projectId }) {
|
||||
id
|
||||
...ProjectLayout_ProjectFragment
|
||||
schemaPolicy {
|
||||
id
|
||||
updatedAt
|
||||
...PolicySettings_SchemaPolicyFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const UpdateSchemaPolicyForProject = graphql(`
|
||||
mutation UpdateSchemaPolicyForProject(
|
||||
$selector: ProjectSelectorInput!
|
||||
$policy: SchemaPolicyInput!
|
||||
) {
|
||||
updateSchemaPolicyForProject(selector: $selector, policy: $policy) {
|
||||
error {
|
||||
message
|
||||
}
|
||||
ok {
|
||||
project {
|
||||
id
|
||||
...ProjectLayout_ProjectFragment
|
||||
schemaPolicy {
|
||||
id
|
||||
updatedAt
|
||||
...PolicySettings_SchemaPolicyFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
function ProjectPolicyPage(): ReactElement {
|
||||
const [mutation, mutate] = useMutation(UpdateSchemaPolicyForProject);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title title="Project Schema Policy" />
|
||||
<ProjectLayout
|
||||
value="policy"
|
||||
className="flex flex-col gap-y-10"
|
||||
query={ProjectPolicyPageQuery}
|
||||
>
|
||||
{(props, selector) =>
|
||||
props.project && props.organization ? (
|
||||
<Card>
|
||||
<Heading className="mb-2">Project Schema Policy</Heading>
|
||||
<DocsNote>
|
||||
<strong>Schema Policies</strong> enable developers to define additional semantic
|
||||
checks on the GraphQL schema. At the project level, policies can be defined to
|
||||
affect all targets, and override policy configuration defined at the organization
|
||||
level. <DocsLink href="/features/schema-policy">Learn more</DocsLink>
|
||||
</DocsNote>
|
||||
{props.organization.organization.schemaPolicy?.allowOverrides ? (
|
||||
<PolicySettings
|
||||
saving={mutation.fetching}
|
||||
rulesInParent={props.organization.organization.schemaPolicy?.rules.map(
|
||||
r => r.rule.id,
|
||||
)}
|
||||
error={
|
||||
mutation.error?.message ||
|
||||
mutation.data?.updateSchemaPolicyForProject.error?.message
|
||||
}
|
||||
onSave={async newPolicy => {
|
||||
await mutate({
|
||||
selector,
|
||||
policy: newPolicy,
|
||||
});
|
||||
}}
|
||||
currentState={props.project.schemaPolicy}
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-4 text-gray-400 pl-1 text-sm font-bold">
|
||||
<p className="text-orange-500 inline-block mr-4">!</p>
|
||||
Organization settings does not allow projects to override policy. Please consult
|
||||
your organization administrator.
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
) : null
|
||||
}
|
||||
</ProjectLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const getServerSideProps = withSessionProtection();
|
||||
|
||||
export default authenticated(ProjectPolicyPage);
|
||||
119
packages/web/app/pages/[orgId]/view/policy.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { ReactElement } from 'react';
|
||||
import { useMutation } from 'urql';
|
||||
import { authenticated } from '@/components/authenticated-container';
|
||||
import { OrganizationLayout } from '@/components/layouts';
|
||||
import { PolicySettings } from '@/components/policy/policy-settings';
|
||||
import { Card, Checkbox, DocsLink, DocsNote, Heading, Title } from '@/components/v2';
|
||||
import { graphql } from '@/gql';
|
||||
import { withSessionProtection } from '@/lib/supertokens/guard';
|
||||
|
||||
const OrganizationPolicyPageQuery = graphql(`
|
||||
query OrganizationPolicyPageQuery($selector: OrganizationSelectorInput!) {
|
||||
organization(selector: $selector) {
|
||||
organization {
|
||||
id
|
||||
...OrganizationLayout_OrganizationFragment
|
||||
schemaPolicy {
|
||||
id
|
||||
updatedAt
|
||||
...PolicySettings_SchemaPolicyFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const UpdateSchemaPolicyForOrganization = graphql(`
|
||||
mutation UpdateSchemaPolicyForOrganization(
|
||||
$selector: OrganizationSelectorInput!
|
||||
$policy: SchemaPolicyInput!
|
||||
$allowOverrides: Boolean!
|
||||
) {
|
||||
updateSchemaPolicyForOrganization(
|
||||
selector: $selector
|
||||
policy: $policy
|
||||
allowOverrides: $allowOverrides
|
||||
) {
|
||||
error {
|
||||
message
|
||||
}
|
||||
ok {
|
||||
organization {
|
||||
id
|
||||
...OrganizationLayout_OrganizationFragment
|
||||
schemaPolicy {
|
||||
id
|
||||
updatedAt
|
||||
allowOverrides
|
||||
...PolicySettings_SchemaPolicyFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
function OrganizationPolicyPage(): ReactElement {
|
||||
const [mutation, mutate] = useMutation(UpdateSchemaPolicyForOrganization);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title title="Organization Schema Policy" />
|
||||
<OrganizationLayout
|
||||
value="policy"
|
||||
className="flex flex-col gap-y-10"
|
||||
query={OrganizationPolicyPageQuery}
|
||||
>
|
||||
{(props, selector) =>
|
||||
props.organization ? (
|
||||
<Card>
|
||||
<Heading className="mb-2">Organization Schema Policy</Heading>
|
||||
<DocsNote>
|
||||
<strong>Schema Policies</strong> enable developers to define additional semantic
|
||||
checks on the GraphQL schema. At the organizational level, policies can be defined
|
||||
to affect all projects and targets. At the project level, policies can be overridden
|
||||
or extended. <DocsLink href="/features/schema-policy">Learn more</DocsLink>
|
||||
</DocsNote>
|
||||
<PolicySettings
|
||||
saving={mutation.fetching}
|
||||
error={
|
||||
mutation.error?.message ||
|
||||
mutation.data?.updateSchemaPolicyForOrganization.error?.message
|
||||
}
|
||||
onSave={async (newPolicy, allowOverrides) => {
|
||||
await mutate({
|
||||
selector,
|
||||
policy: newPolicy,
|
||||
allowOverrides,
|
||||
}).catch();
|
||||
}}
|
||||
currentState={props.organization.organization.schemaPolicy}
|
||||
>
|
||||
{form => (
|
||||
<div className="flex pl-1 pt-2">
|
||||
<Checkbox
|
||||
id="allowOverrides"
|
||||
checked={form.values.allowOverrides}
|
||||
value="allowOverrides"
|
||||
onCheckedChange={newValue => form.setFieldValue('allowOverrides', newValue)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="allowOverrides"
|
||||
className="inline-block ml-2 text-sm text-gray-300"
|
||||
>
|
||||
Allow projects to override or disable rules
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</PolicySettings>
|
||||
</Card>
|
||||
) : null
|
||||
}
|
||||
</OrganizationLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const getServerSideProps = withSessionProtection();
|
||||
|
||||
export default authenticated(OrganizationPolicyPage);
|
||||
|
|
@ -21,6 +21,7 @@ enum TabValue {
|
|||
Overview = 'overview',
|
||||
Members = 'members',
|
||||
Settings = 'settings',
|
||||
Policy = 'policy',
|
||||
Subscription = 'subscription',
|
||||
}
|
||||
|
||||
|
|
@ -48,8 +49,13 @@ export function OrganizationLayout<
|
|||
query,
|
||||
className,
|
||||
}: {
|
||||
children(props: TSatisfiesType): ReactNode;
|
||||
value?: 'overview' | 'members' | 'settings' | 'subscription';
|
||||
children(
|
||||
props: TSatisfiesType,
|
||||
selector: {
|
||||
organization: string;
|
||||
},
|
||||
): ReactNode;
|
||||
value?: 'overview' | 'members' | 'settings' | 'subscription' | 'policy';
|
||||
className?: string;
|
||||
query: TypedDocumentNode<
|
||||
TSatisfiesType,
|
||||
|
|
@ -105,7 +111,13 @@ export function OrganizationLayout<
|
|||
}
|
||||
|
||||
if (!value) {
|
||||
return <>{children(organizationQuery.data!)}</>;
|
||||
return (
|
||||
<>
|
||||
{children(organizationQuery.data!, {
|
||||
organization: orgId,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -137,9 +149,14 @@ export function OrganizationLayout<
|
|||
</Tabs.Trigger>
|
||||
)}
|
||||
{canAccessOrganization(OrganizationAccessScope.Settings, me) && (
|
||||
<Tabs.Trigger value={TabValue.Settings} asChild>
|
||||
<NextLink href={`/${orgId}/view/${TabValue.Settings}`}>Settings</NextLink>
|
||||
</Tabs.Trigger>
|
||||
<>
|
||||
<Tabs.Trigger value={TabValue.Policy} asChild>
|
||||
<NextLink href={`/${orgId}/view/${TabValue.Policy}`}>Policy</NextLink>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value={TabValue.Settings} asChild>
|
||||
<NextLink href={`/${orgId}/view/${TabValue.Settings}`}>Settings</NextLink>
|
||||
</Tabs.Trigger>
|
||||
</>
|
||||
)}
|
||||
{getIsStripeEnabled() && canAccessOrganization(OrganizationAccessScope.Settings, me) && (
|
||||
<Tabs.Trigger value={TabValue.Subscription} asChild>
|
||||
|
|
@ -148,7 +165,9 @@ export function OrganizationLayout<
|
|||
)}
|
||||
</Tabs.List>
|
||||
<Tabs.Content value={value} className={className}>
|
||||
{children(organizationQuery.data!)}
|
||||
{children(organizationQuery.data!, {
|
||||
organization: orgId,
|
||||
})}
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { ProjectMigrationToast } from '../project/migration-toast';
|
|||
enum TabValue {
|
||||
Targets = 'targets',
|
||||
Alerts = 'alerts',
|
||||
Policy = 'policy',
|
||||
Settings = 'settings',
|
||||
}
|
||||
|
||||
|
|
@ -60,11 +61,17 @@ export function ProjectLayout<
|
|||
className,
|
||||
query,
|
||||
}: {
|
||||
children(props: {
|
||||
project: Exclude<TSatisfiesType['project'], null | undefined>;
|
||||
organization: Exclude<TSatisfiesType['organization'], null | undefined>;
|
||||
}): ReactNode;
|
||||
value: 'targets' | 'alerts' | 'settings';
|
||||
children(
|
||||
props: {
|
||||
project: Exclude<TSatisfiesType['project'], null | undefined>;
|
||||
organization: Exclude<TSatisfiesType['organization'], null | undefined>;
|
||||
},
|
||||
selector: {
|
||||
organization: string;
|
||||
project: string;
|
||||
},
|
||||
): ReactNode;
|
||||
value: 'targets' | 'alerts' | 'settings' | 'policy';
|
||||
className?: string;
|
||||
query: TypedDocumentNode<
|
||||
TSatisfiesType,
|
||||
|
|
@ -192,26 +199,39 @@ export function ProjectLayout<
|
|||
</Tabs.Trigger>
|
||||
{canAccessProject(ProjectAccessScope.Alerts, organization.me) && (
|
||||
<Tabs.Trigger value={TabValue.Alerts} asChild>
|
||||
<NextLink href={`/${orgId}/${projectId}/view/alerts`}>Alerts</NextLink>
|
||||
<NextLink href={`/${orgId}/${projectId}/view/${TabValue.Alerts}`}>Alerts</NextLink>
|
||||
</Tabs.Trigger>
|
||||
)}
|
||||
{canAccessProject(ProjectAccessScope.Settings, organization.me) && (
|
||||
<Tabs.Trigger value={TabValue.Settings} asChild>
|
||||
<NextLink href={`/${orgId}/${projectId}/view/settings`}>Settings</NextLink>
|
||||
</Tabs.Trigger>
|
||||
<>
|
||||
<Tabs.Trigger value={TabValue.Policy} asChild>
|
||||
<NextLink href={`/${orgId}/${projectId}/view/${TabValue.Policy}`}>Policy</NextLink>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value={TabValue.Settings} asChild>
|
||||
<NextLink href={`/${orgId}/${projectId}/view/${TabValue.Settings}`}>
|
||||
Settings
|
||||
</NextLink>
|
||||
</Tabs.Trigger>
|
||||
</>
|
||||
)}
|
||||
</Tabs.List>
|
||||
<Tabs.Content value={value} className={className}>
|
||||
{children({
|
||||
project: projectQuery.data?.project as Exclude<
|
||||
TSatisfiesType['project'],
|
||||
null | undefined
|
||||
>,
|
||||
organization: projectQuery.data?.organization as Exclude<
|
||||
TSatisfiesType['organization'],
|
||||
null | undefined
|
||||
>,
|
||||
})}
|
||||
{children(
|
||||
{
|
||||
project: projectQuery.data?.project as Exclude<
|
||||
TSatisfiesType['project'],
|
||||
null | undefined
|
||||
>,
|
||||
organization: projectQuery.data?.organization as Exclude<
|
||||
TSatisfiesType['organization'],
|
||||
null | undefined
|
||||
>,
|
||||
},
|
||||
{
|
||||
organization: orgId,
|
||||
project: projectId,
|
||||
},
|
||||
)}
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</>
|
||||
|
|
|
|||
54
packages/web/app/src/components/policy/form-helper.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { useFormikContext } from 'formik';
|
||||
import { RuleInstanceSeverityLevel } from '@/graphql';
|
||||
import type { PolicyFormValues } from './rules-configuration';
|
||||
|
||||
export function useConfigurationHelper() {
|
||||
const formik = useFormikContext<PolicyFormValues>();
|
||||
|
||||
return {
|
||||
ruleConfig(id: string) {
|
||||
return {
|
||||
enabled: formik.values.rules[id]?.enabled ?? false,
|
||||
severity: formik.values.rules[id]?.severity,
|
||||
config: formik.values.rules[id]?.config,
|
||||
getConfigAsString() {
|
||||
return JSON.stringify(formik.values.rules[id]?.config, null, 2);
|
||||
},
|
||||
setConfig(property: string, value: any) {
|
||||
const actualProp = property === '' ? '' : `.${property}`;
|
||||
|
||||
if (value && Array.isArray(value) && value.length === 0) {
|
||||
formik.setFieldValue(`rules.${id}.config${actualProp}`, undefined, true);
|
||||
} else {
|
||||
formik.setFieldValue(`rules.${id}.config${actualProp}`, value, true);
|
||||
}
|
||||
},
|
||||
getConfigValue<T>(property: string): T | undefined {
|
||||
const levels = property.split('.');
|
||||
let propName: string | undefined;
|
||||
let obj = formik.values.rules[id]?.config;
|
||||
|
||||
do {
|
||||
propName = levels.shift();
|
||||
|
||||
if (propName) {
|
||||
obj = obj && typeof obj === 'object' ? (obj as any)[propName] : undefined;
|
||||
}
|
||||
} while (propName && obj);
|
||||
|
||||
return obj as any as T;
|
||||
},
|
||||
setSeverity(severity: RuleInstanceSeverityLevel) {
|
||||
formik.setFieldValue(`rules.${id}.severity`, severity, true);
|
||||
},
|
||||
toggleRuleState(newValue: boolean) {
|
||||
formik.setFieldValue(`rules.${id}.enabled`, newValue, true);
|
||||
|
||||
if (newValue && !formik.values.rules[id]?.severity) {
|
||||
formik.setFieldValue(`rules.${id}.severity`, RuleInstanceSeverityLevel.Warning, true);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
27
packages/web/app/src/components/policy/policy-config-box.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { ReactElement } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
} & { children: React.ReactNode; title?: ReactElement | string };
|
||||
|
||||
export function PolicyConfigBox(props: Props) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'px-4 items-center font-mono pb-2',
|
||||
'title' in props ? undefined : 'flex',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{'title' in props ? (
|
||||
<>
|
||||
<div className="text-xs pb-1 text-gray-600">{props.title}</div>
|
||||
<div>{props.children}</div>
|
||||
</>
|
||||
) : (
|
||||
props.children
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
packages/web/app/src/components/policy/policy-list-item.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { ReactElement } from 'react';
|
||||
import type { JSONSchema } from 'json-schema-typed';
|
||||
import { Markdown } from '@/components/v2/markdown';
|
||||
import { FragmentType, graphql, useFragment } from '@/gql';
|
||||
import { RuleInstanceSeverityLevel } from '@/graphql';
|
||||
import { Checkbox, DocsLink, Tooltip } from '../v2';
|
||||
import { useConfigurationHelper } from './form-helper';
|
||||
import { PolicyRuleConfig } from './rules-configuration';
|
||||
import { SeverityLevelToggle } from './rules-configuration/severity-toggle';
|
||||
|
||||
const PolicyListItem_RuleInfoFragment = graphql(`
|
||||
fragment PolicyListItem_RuleInfoFragment on SchemaPolicyRule {
|
||||
id
|
||||
description
|
||||
recommended
|
||||
documentationUrl
|
||||
configJsonSchema
|
||||
}
|
||||
`);
|
||||
|
||||
function extractBaseSchema(configJsonSchema: JSONSchema | null | undefined): JSONSchema | null {
|
||||
if (
|
||||
configJsonSchema &&
|
||||
typeof configJsonSchema === 'object' &&
|
||||
configJsonSchema.type === 'array' &&
|
||||
typeof configJsonSchema.items === 'object' &&
|
||||
configJsonSchema.items.type === 'object'
|
||||
) {
|
||||
return configJsonSchema.items;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function PolicyListItem(props: {
|
||||
ruleInfo: FragmentType<typeof PolicyListItem_RuleInfoFragment>;
|
||||
overridingParentRule: boolean;
|
||||
}): ReactElement {
|
||||
const config = useConfigurationHelper();
|
||||
const ruleInfo = useFragment(PolicyListItem_RuleInfoFragment, props.ruleInfo);
|
||||
const { enabled, toggleRuleState, severity } = config.ruleConfig(ruleInfo.id);
|
||||
const shouldShowRuleConfig =
|
||||
!props.overridingParentRule ||
|
||||
(props.overridingParentRule && severity !== RuleInstanceSeverityLevel.Off);
|
||||
|
||||
return (
|
||||
<Tooltip.Provider delayDuration={100}>
|
||||
<div className="py-4 px-1">
|
||||
<div className="flex gap-4">
|
||||
<div>
|
||||
<Checkbox
|
||||
id={ruleInfo.id}
|
||||
value={ruleInfo.id}
|
||||
checked={enabled}
|
||||
onCheckedChange={newState => toggleRuleState(newState as boolean)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div className="mb-2">
|
||||
<Tooltip
|
||||
contentProps={{
|
||||
className: 'block max-w-[500px]',
|
||||
side: 'top',
|
||||
align: 'start',
|
||||
}}
|
||||
content={
|
||||
<>
|
||||
<Markdown content={ruleInfo.description} className="text-sm" />
|
||||
<br />
|
||||
{ruleInfo.documentationUrl ? (
|
||||
<DocsLink href={ruleInfo.documentationUrl}>read more</DocsLink>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<label htmlFor={ruleInfo.id} className="font-mono font-bold">
|
||||
{ruleInfo.id}
|
||||
</label>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{enabled ? (
|
||||
<div className="flex w-full">
|
||||
<div>
|
||||
<SeverityLevelToggle canTurnOff={props.overridingParentRule} rule={ruleInfo.id} />
|
||||
</div>
|
||||
<div className="grow grid grid-cols-4 [&>*]:border-l-gray-800 [&>*]:border-l-[1px] align-middle [&>*]:min-h-[40px]">
|
||||
{shouldShowRuleConfig && (
|
||||
<PolicyRuleConfig
|
||||
rule={ruleInfo.id}
|
||||
configJsonSchema={extractBaseSchema(ruleInfo.configJsonSchema)}
|
||||
baseDocumentationUrl={ruleInfo.documentationUrl ?? undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{props.overridingParentRule && enabled ? (
|
||||
<div className="text-xs font-medium mt-4 text-gray-400">
|
||||
<p className="text-orange-500 text-sm font-medium inline-block mr-2">!</p>
|
||||
You are {severity === RuleInstanceSeverityLevel.Off ? 'disabling' : 'overriding'} a
|
||||
rule configured at the organization level
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
||||
176
packages/web/app/src/components/policy/policy-settings.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { ReactElement, useMemo, useRef } from 'react';
|
||||
import { Formik, FormikHelpers, FormikProps } from 'formik';
|
||||
import { useQuery } from 'urql';
|
||||
import { FragmentType, graphql, useFragment } from '@/gql';
|
||||
import {
|
||||
PolicySettings_SchemaPolicyFragmentFragment,
|
||||
RuleInstanceSeverityLevel,
|
||||
SchemaPolicyInput,
|
||||
} from '@/graphql';
|
||||
import type { ResultOf } from '@graphql-typed-document-node/core';
|
||||
import { Button, Callout, DataWrapper } from '../v2';
|
||||
import { PolicyListItem } from './policy-list-item';
|
||||
import { buildValidationSchema, PolicyFormValues } from './rules-configuration';
|
||||
|
||||
const PolicySettingsAvailableRulesQuery = graphql(`
|
||||
query PolicySettingsAvailableRulesQuery {
|
||||
schemaPolicyRules {
|
||||
id
|
||||
configJsonSchema
|
||||
...PolicyListItem_RuleInfoFragment
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const PolicySettings_SchemaPolicyFragment = graphql(`
|
||||
fragment PolicySettings_SchemaPolicyFragment on SchemaPolicy {
|
||||
id
|
||||
allowOverrides
|
||||
rules {
|
||||
rule {
|
||||
id
|
||||
}
|
||||
severity
|
||||
configuration
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export type AvailableRulesList = ResultOf<
|
||||
typeof PolicySettingsAvailableRulesQuery
|
||||
>['schemaPolicyRules'];
|
||||
|
||||
function PolicySettingsListForm({
|
||||
rulesInParent,
|
||||
saving,
|
||||
onSave,
|
||||
currentState,
|
||||
availableRules,
|
||||
error,
|
||||
children,
|
||||
}: {
|
||||
saving?: boolean;
|
||||
rulesInParent?: string[];
|
||||
error?: string;
|
||||
onSave: (values: SchemaPolicyInput, allowOverrides: boolean) => Promise<void>;
|
||||
availableRules: AvailableRulesList;
|
||||
currentState?: PolicySettings_SchemaPolicyFragmentFragment | null;
|
||||
children?: (form: FormikProps<PolicyFormValues>) => ReactElement;
|
||||
}): ReactElement {
|
||||
const onSubmit = useRef(
|
||||
(values: PolicyFormValues, formikHelpers: FormikHelpers<PolicyFormValues>) => {
|
||||
const asInput: SchemaPolicyInput = {
|
||||
rules: Object.entries(values.rules)
|
||||
.filter(([, ruleConfig]) => ruleConfig.enabled)
|
||||
.map(([ruleId, ruleConfig]) => ({
|
||||
ruleId,
|
||||
severity: ruleConfig.severity,
|
||||
configuration:
|
||||
ruleConfig.enabled && ruleConfig.severity !== RuleInstanceSeverityLevel.Off
|
||||
? ruleConfig.config
|
||||
: null,
|
||||
})),
|
||||
};
|
||||
|
||||
void onSave(asInput, values.allowOverrides).then(() => formikHelpers.resetForm());
|
||||
},
|
||||
);
|
||||
const validationSchema = useMemo(() => buildValidationSchema(availableRules), [availableRules]);
|
||||
const initialState = useMemo(() => {
|
||||
return {
|
||||
allowOverrides: currentState?.allowOverrides ?? true,
|
||||
rules:
|
||||
currentState?.rules.reduce((acc, ruleInstance) => {
|
||||
return {
|
||||
...acc,
|
||||
[ruleInstance.rule.id]: {
|
||||
enabled: true,
|
||||
severity: ruleInstance.severity,
|
||||
config: ruleInstance.configuration,
|
||||
},
|
||||
};
|
||||
}, {} as PolicyFormValues['rules']) ?? {},
|
||||
};
|
||||
}, [currentState]);
|
||||
|
||||
return (
|
||||
<Formik<PolicyFormValues>
|
||||
initialValues={initialState}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={onSubmit.current}
|
||||
enableReinitialize
|
||||
>
|
||||
{props => (
|
||||
<>
|
||||
{children ? children(props) : null}
|
||||
<div className="justify-end flex items-center">
|
||||
{props.dirty ? <p className="pr-2 text-sm text-gray-500">Unsaved changes</p> : null}
|
||||
<Button
|
||||
disabled={!props.dirty || saving}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
onClick={() => props.submitForm()}
|
||||
>
|
||||
Update Policy
|
||||
</Button>
|
||||
</div>
|
||||
{error ? (
|
||||
<Callout type="error" className="w-2/3 mx-auto">
|
||||
<b>Oops, something went wrong.</b>
|
||||
<br />
|
||||
{error}
|
||||
</Callout>
|
||||
) : null}
|
||||
<div className="grid grid-cols-1 divide-y divide-gray-800">
|
||||
{availableRules.map(availableRule => (
|
||||
<PolicyListItem
|
||||
overridingParentRule={rulesInParent?.includes(availableRule.id) ?? false}
|
||||
key={availableRule.id}
|
||||
ruleInfo={availableRule}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
}
|
||||
|
||||
export function PolicySettings({
|
||||
rulesInParent,
|
||||
saving,
|
||||
currentState,
|
||||
onSave,
|
||||
error,
|
||||
children,
|
||||
}: {
|
||||
saving?: boolean;
|
||||
rulesInParent?: string[];
|
||||
currentState?: null | FragmentType<typeof PolicySettings_SchemaPolicyFragment>;
|
||||
onSave: (values: SchemaPolicyInput, allowOverrides: boolean) => Promise<void>;
|
||||
error?: string;
|
||||
children?: (form: FormikProps<PolicyFormValues>) => ReactElement;
|
||||
}): ReactElement {
|
||||
const [availableRules] = useQuery({
|
||||
query: PolicySettingsAvailableRulesQuery,
|
||||
variables: {},
|
||||
});
|
||||
const activePolicy = useFragment(PolicySettings_SchemaPolicyFragment, currentState);
|
||||
|
||||
return (
|
||||
<DataWrapper query={availableRules}>
|
||||
{query => (
|
||||
<PolicySettingsListForm
|
||||
saving={saving}
|
||||
rulesInParent={rulesInParent}
|
||||
currentState={activePolicy}
|
||||
onSave={onSave}
|
||||
error={error}
|
||||
availableRules={query.data.schemaPolicyRules}
|
||||
>
|
||||
{children}
|
||||
</PolicySettingsListForm>
|
||||
)}
|
||||
</DataWrapper>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { ReactElement, useEffect } from 'react';
|
||||
import { Checkbox, Tooltip } from '@/components/v2';
|
||||
import { useConfigurationHelper } from '../form-helper';
|
||||
import { PolicyConfigBox } from '../policy-config-box';
|
||||
|
||||
export const PolicyBooleanToggle = (props: {
|
||||
rule: string;
|
||||
title: string;
|
||||
propertyName: string;
|
||||
defaultValue: boolean;
|
||||
tooltip?: ReactElement;
|
||||
}): ReactElement => {
|
||||
const { config, setConfig, getConfigValue } = useConfigurationHelper().ruleConfig(props.rule);
|
||||
const currentValue = getConfigValue<boolean>(props.propertyName);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) {
|
||||
setConfig(props.propertyName, props.defaultValue);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const label = (
|
||||
<label
|
||||
className="text-xs pb-1 text-gray-500 pl-2 font-mono"
|
||||
htmlFor={`${props.rule}_${props.propertyName}`}
|
||||
>
|
||||
{props.title}
|
||||
</label>
|
||||
);
|
||||
|
||||
return (
|
||||
<PolicyConfigBox>
|
||||
<div>
|
||||
<Checkbox
|
||||
id={`${props.rule}_${props.propertyName}`}
|
||||
value={props.rule}
|
||||
checked={currentValue}
|
||||
onCheckedChange={newValue => setConfig(props.propertyName, newValue)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grow">
|
||||
{props.tooltip ? <Tooltip content={props.tooltip}>{label}</Tooltip> : label}
|
||||
</div>
|
||||
</PolicyConfigBox>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { ReactElement, useEffect } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { InfoCircledIcon } from '@radix-ui/react-icons';
|
||||
import { ToggleGroup, ToggleGroupItem, Tooltip } from '../../v2';
|
||||
import { useConfigurationHelper } from '../form-helper';
|
||||
import { PolicyConfigBox } from '../policy-config-box';
|
||||
|
||||
export const PolicyEnumSelect = (props: {
|
||||
rule: string;
|
||||
propertyName: string;
|
||||
defaultValue: string;
|
||||
title: string;
|
||||
tooltip?: ReactElement;
|
||||
options: {
|
||||
value: string;
|
||||
label: string;
|
||||
}[];
|
||||
}): ReactElement => {
|
||||
const { config, setConfig, getConfigValue } = useConfigurationHelper().ruleConfig(props.rule);
|
||||
const currentValue = getConfigValue<string>(props.propertyName);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) {
|
||||
setConfig(props.propertyName, props.defaultValue);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PolicyConfigBox
|
||||
title={
|
||||
<div className="items-center flex">
|
||||
<div>{props.title}</div>
|
||||
{props.tooltip ? (
|
||||
<Tooltip content={props.tooltip}>
|
||||
<InfoCircledIcon className="ml-2 text-orange-500" />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ToggleGroup
|
||||
defaultValue="list"
|
||||
onValueChange={newValue => {
|
||||
if (newValue) {
|
||||
setConfig(props.propertyName, newValue);
|
||||
}
|
||||
}}
|
||||
value={currentValue}
|
||||
type="single"
|
||||
className="bg-gray-900/50 text-gray-500"
|
||||
>
|
||||
{props.options.map(option => (
|
||||
<ToggleGroupItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
title={option.label}
|
||||
className={clsx(
|
||||
'hover:text-white text-xs',
|
||||
currentValue === option.value && 'bg-gray-800 text-white',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</PolicyConfigBox>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
import { ReactElement } from 'react';
|
||||
import type { JSONSchema } from 'json-schema-typed';
|
||||
import convertToYup from 'json-schema-yup-transformer';
|
||||
import * as Yup from 'yup';
|
||||
import { DocsLink } from '@/components/v2';
|
||||
import { Markdown } from '@/components/v2/markdown';
|
||||
import { RuleInstanceSeverityLevel } from '@/graphql';
|
||||
import type { AvailableRulesList } from '../policy-settings';
|
||||
import { PolicyBooleanToggle } from './boolean-config';
|
||||
import { PolicyEnumSelect } from './enum-config';
|
||||
import { PolicyMultiSelect } from './multiselect-config';
|
||||
import { NamingConventionConfigEditor } from './naming-convention-rule-editor';
|
||||
import { PolicyStringInputConfig } from './string-config';
|
||||
|
||||
export type PolicyFormValues = {
|
||||
allowOverrides: boolean;
|
||||
rules: Record<string, { enabled: boolean; severity: RuleInstanceSeverityLevel; config: unknown }>;
|
||||
};
|
||||
|
||||
function composeTooltipContent(input: {
|
||||
description?: string;
|
||||
documentationUrl?: string;
|
||||
}): undefined | ReactElement {
|
||||
const elements = [
|
||||
input.description ? (
|
||||
<Markdown key="docs" className="text-sm" content={input.description} />
|
||||
) : null,
|
||||
input.documentationUrl ? (
|
||||
<DocsLink key="link" href={input.documentationUrl}>
|
||||
read more
|
||||
</DocsLink>
|
||||
) : null,
|
||||
].filter(Boolean);
|
||||
|
||||
if (elements.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return <>{elements}</>;
|
||||
}
|
||||
|
||||
function composeDocsLink(
|
||||
baseUrl: string,
|
||||
propertyName: string,
|
||||
schema: JSONSchema,
|
||||
): string | undefined {
|
||||
const attributes: string[] = [];
|
||||
|
||||
if (typeof schema === 'object') {
|
||||
if (schema.enum) {
|
||||
attributes.push('enum');
|
||||
} else if (schema.type) {
|
||||
attributes.push(String(schema.type));
|
||||
}
|
||||
|
||||
if (schema.required || schema.minItems) {
|
||||
attributes.push('required');
|
||||
}
|
||||
}
|
||||
|
||||
return attributes.length === 0
|
||||
? undefined
|
||||
: `${baseUrl}#${[propertyName.toLowerCase(), ...attributes].join('-')}`;
|
||||
}
|
||||
|
||||
export function buildValidationSchema(availableRules: AvailableRulesList) {
|
||||
return Yup.object().shape(
|
||||
availableRules.reduce((acc, rule) => {
|
||||
return {
|
||||
...acc,
|
||||
[rule.id]: Yup.object()
|
||||
.shape({
|
||||
severity: Yup.mixed()
|
||||
.oneOf([
|
||||
RuleInstanceSeverityLevel.Off,
|
||||
RuleInstanceSeverityLevel.Warning,
|
||||
RuleInstanceSeverityLevel.Error,
|
||||
])
|
||||
.required(),
|
||||
config: rule.configJsonSchema
|
||||
? convertToYup(rule.configJsonSchema as object)!
|
||||
: Yup.object().nullable(),
|
||||
})
|
||||
.optional()
|
||||
.default(undefined),
|
||||
};
|
||||
}, {}),
|
||||
);
|
||||
}
|
||||
|
||||
export function PolicyRuleConfig({
|
||||
rule,
|
||||
basePropertyName = '',
|
||||
configJsonSchema,
|
||||
baseDocumentationUrl,
|
||||
}: {
|
||||
rule: string;
|
||||
configJsonSchema: JSONSchema | null;
|
||||
basePropertyName?: string;
|
||||
baseDocumentationUrl?: string;
|
||||
}): ReactElement | null {
|
||||
if (rule === 'naming-convention') {
|
||||
return <NamingConventionConfigEditor configJsonSchema={configJsonSchema} />;
|
||||
}
|
||||
|
||||
if (
|
||||
configJsonSchema &&
|
||||
typeof configJsonSchema === 'object' &&
|
||||
configJsonSchema.type === 'object'
|
||||
) {
|
||||
const configProperties = configJsonSchema.properties;
|
||||
|
||||
if (configProperties && Object.keys(configProperties).length > 0) {
|
||||
return (
|
||||
<>
|
||||
{Object.entries(configProperties).map(([flatPropName, propertySchema]) => {
|
||||
const propertyName = basePropertyName
|
||||
? `${basePropertyName}.${flatPropName}`
|
||||
: flatPropName;
|
||||
|
||||
if (typeof propertySchema === 'object') {
|
||||
const documentationUrl = baseDocumentationUrl
|
||||
? composeDocsLink(baseDocumentationUrl, propertyName, propertySchema)
|
||||
: undefined;
|
||||
|
||||
if (propertySchema.type === 'array' && typeof propertySchema.items === 'object') {
|
||||
if (propertySchema.items.enum) {
|
||||
return (
|
||||
<PolicyMultiSelect
|
||||
key={propertyName}
|
||||
title={propertyName}
|
||||
rule={rule}
|
||||
defaultValues={propertySchema.default}
|
||||
propertyName={propertyName}
|
||||
tooltip={composeTooltipContent({
|
||||
description: propertySchema.description,
|
||||
documentationUrl,
|
||||
})}
|
||||
options={propertySchema.items.enum.map((v: string) => ({
|
||||
label: v,
|
||||
value: v,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PolicyMultiSelect
|
||||
key={propertyName}
|
||||
title={propertyName}
|
||||
rule={rule}
|
||||
defaultValues={propertySchema.default}
|
||||
propertyName={propertyName}
|
||||
tooltip={composeTooltipContent({
|
||||
description: propertySchema.description,
|
||||
documentationUrl,
|
||||
})}
|
||||
options={
|
||||
propertySchema.default?.map((v: string) => ({ label: v, value: v })) || []
|
||||
}
|
||||
creatable
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (propertySchema.type === 'object') {
|
||||
return (
|
||||
<PolicyRuleConfig
|
||||
rule={rule}
|
||||
basePropertyName={propertyName}
|
||||
key={propertyName}
|
||||
baseDocumentationUrl={baseDocumentationUrl}
|
||||
configJsonSchema={propertySchema}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (propertySchema.type === 'boolean') {
|
||||
return (
|
||||
<PolicyBooleanToggle
|
||||
key={propertyName}
|
||||
rule={rule}
|
||||
defaultValue={propertySchema.default}
|
||||
propertyName={propertyName}
|
||||
title={propertyName}
|
||||
tooltip={composeTooltipContent({
|
||||
description: propertySchema.description,
|
||||
documentationUrl,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (propertySchema.type === 'string') {
|
||||
return (
|
||||
<PolicyStringInputConfig
|
||||
key={propertyName}
|
||||
rule={rule}
|
||||
defaultValue={propertySchema.default}
|
||||
propertyName={propertyName}
|
||||
title={propertyName}
|
||||
tooltip={composeTooltipContent({
|
||||
description: propertySchema.description,
|
||||
documentationUrl,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (propertySchema.enum) {
|
||||
return (
|
||||
<PolicyEnumSelect
|
||||
key={propertyName}
|
||||
title={propertyName}
|
||||
rule={rule}
|
||||
defaultValue={propertySchema.default}
|
||||
propertyName={propertyName}
|
||||
tooltip={composeTooltipContent({
|
||||
description: propertySchema.description,
|
||||
documentationUrl,
|
||||
})}
|
||||
options={propertySchema.enum.map(v => ({ label: v, value: v }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(`Unsupported property type: ${propertyName}`, propertySchema);
|
||||
|
||||
return null;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { ReactElement, useEffect } from 'react';
|
||||
import { Tooltip } from '@/components/v2';
|
||||
import { Combobox } from '@/components/v2/combobox';
|
||||
import { InfoCircledIcon } from '@radix-ui/react-icons';
|
||||
import { useConfigurationHelper } from '../form-helper';
|
||||
import { PolicyConfigBox } from '../policy-config-box';
|
||||
|
||||
export const PolicyMultiSelect = (props: {
|
||||
rule: string;
|
||||
propertyName: string;
|
||||
defaultValues: string[];
|
||||
title: string;
|
||||
tooltip?: ReactElement;
|
||||
options: {
|
||||
value: string;
|
||||
label: string;
|
||||
}[];
|
||||
creatable?: boolean;
|
||||
}): ReactElement => {
|
||||
const { config, setConfig, getConfigValue } = useConfigurationHelper().ruleConfig(props.rule);
|
||||
const currentValues = getConfigValue<string[]>(props.propertyName);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config && typeof props.defaultValues !== undefined) {
|
||||
setConfig(props.propertyName, props.defaultValues);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PolicyConfigBox
|
||||
title={
|
||||
<div className="items-center flex">
|
||||
<div>{props.title}</div>
|
||||
{props.tooltip ? (
|
||||
<Tooltip content={props.tooltip}>
|
||||
<InfoCircledIcon className="ml-2 text-orange-500" />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Combobox
|
||||
name="Select Options"
|
||||
className="w-full"
|
||||
onBlur={() => {}}
|
||||
onChange={newValue => {
|
||||
setConfig(
|
||||
props.propertyName,
|
||||
newValue.map(o => o.value),
|
||||
);
|
||||
}}
|
||||
creatable={props.creatable}
|
||||
options={props.options || []}
|
||||
value={(currentValues ?? []).map(v => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}))}
|
||||
/>
|
||||
</PolicyConfigBox>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { ReactElement, useEffect } from 'react';
|
||||
import type { JSONSchema } from 'json-schema-typed';
|
||||
import { Spinner } from '@/components/v2';
|
||||
import MonacoEditor, { type Monaco } from '@monaco-editor/react';
|
||||
import { useConfigurationHelper } from '../form-helper';
|
||||
|
||||
const DEFAULT_VALUE = {
|
||||
types: 'PascalCase',
|
||||
FieldDefinition: 'camelCase',
|
||||
InputValueDefinition: 'camelCase',
|
||||
Argument: 'camelCase',
|
||||
DirectiveDefinition: 'camelCase',
|
||||
EnumValueDefinition: 'UPPER_CASE',
|
||||
'FieldDefinition[parent.name.value=Query]': {
|
||||
forbiddenPrefixes: ['query', 'get'],
|
||||
forbiddenSuffixes: ['Query'],
|
||||
},
|
||||
'FieldDefinition[parent.name.value=Mutation]': {
|
||||
forbiddenPrefixes: ['mutation'],
|
||||
forbiddenSuffixes: ['Mutation'],
|
||||
},
|
||||
'FieldDefinition[parent.name.value=Subscription]': {
|
||||
forbiddenPrefixes: ['subscription'],
|
||||
forbiddenSuffixes: ['Subscription'],
|
||||
},
|
||||
};
|
||||
|
||||
export function NamingConventionConfigEditor(props: {
|
||||
configJsonSchema: JSONSchema | null;
|
||||
}): ReactElement {
|
||||
const { config, setConfig, getConfigValue } =
|
||||
useConfigurationHelper().ruleConfig('naming-convention');
|
||||
const currentValue = getConfigValue<string | undefined>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) {
|
||||
setConfig('', DEFAULT_VALUE);
|
||||
}
|
||||
}, []);
|
||||
|
||||
function handleEditorWillMount(monaco: Monaco) {
|
||||
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
|
||||
allowComments: false,
|
||||
comments: 'error',
|
||||
enableSchemaRequest: false,
|
||||
schemaValidation: 'error',
|
||||
trailingCommas: 'error',
|
||||
validate: true,
|
||||
schemaRequest: 'ignore',
|
||||
schemas: [
|
||||
{
|
||||
uri: 'https://example.com/naming-convention-schema.json',
|
||||
fileMatch: ['*'],
|
||||
schema: props.configJsonSchema,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="col-span-4">
|
||||
<MonacoEditor
|
||||
theme="vs-dark"
|
||||
loading={<Spinner />}
|
||||
height="40vh"
|
||||
beforeMount={handleEditorWillMount}
|
||||
width="100%"
|
||||
language="json"
|
||||
onChange={value => {
|
||||
setConfig('', JSON.parse(value as string));
|
||||
}}
|
||||
options={{
|
||||
lineNumbers: 'off',
|
||||
}}
|
||||
defaultValue={JSON.stringify(currentValue, null, 2)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { ReactElement } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { RuleInstanceSeverityLevel } from '@/graphql';
|
||||
import { CrossCircledIcon, ExclamationTriangleIcon, MinusCircledIcon } from '@radix-ui/react-icons';
|
||||
import { ToggleGroup, ToggleGroupItem, Tooltip } from '../../v2';
|
||||
import { useConfigurationHelper } from '../form-helper';
|
||||
import { PolicyConfigBox } from '../policy-config-box';
|
||||
|
||||
export const SeverityLevelToggle = (props: { rule: string; canTurnOff: boolean }): ReactElement => {
|
||||
const config = useConfigurationHelper().ruleConfig(props.rule);
|
||||
const options = [
|
||||
{
|
||||
value: RuleInstanceSeverityLevel.Warning,
|
||||
label: 'Warning',
|
||||
icon: (active: boolean) => (
|
||||
<ExclamationTriangleIcon
|
||||
className={clsx(active ? 'text-orange-600' : 'text-gray-600', 'hover:text-orange-600')}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: RuleInstanceSeverityLevel.Error,
|
||||
label: 'Error',
|
||||
icon: (active: boolean) => (
|
||||
<CrossCircledIcon
|
||||
className={clsx(active ? 'text-red-600' : 'text-gray-600', 'hover:text-red-600')}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (props.canTurnOff) {
|
||||
options.unshift({
|
||||
value: RuleInstanceSeverityLevel.Off,
|
||||
label: 'Disables a rule defined at the organization level',
|
||||
icon: (active: boolean) => (
|
||||
<MinusCircledIcon
|
||||
className={clsx(active ? 'text-white' : 'text-gray-600', 'hover:text-white')}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<PolicyConfigBox title="severity" className="first:pl-0 row-start-1 row-end-6">
|
||||
<ToggleGroup
|
||||
defaultValue="list"
|
||||
onValueChange={newValue => {
|
||||
if (newValue) {
|
||||
config.setSeverity(newValue as RuleInstanceSeverityLevel);
|
||||
}
|
||||
}}
|
||||
type="single"
|
||||
className="bg-gray-900/50 text-gray-500"
|
||||
>
|
||||
{options.map(
|
||||
level =>
|
||||
level && (
|
||||
<ToggleGroupItem
|
||||
key={level.value}
|
||||
value={level.value}
|
||||
title={level.label}
|
||||
className={clsx(
|
||||
'hover:text-white',
|
||||
config.severity === level.value && 'bg-gray-800 text-white',
|
||||
)}
|
||||
>
|
||||
<Tooltip content={level.label}>
|
||||
{level.icon(config.severity === level.value)}
|
||||
</Tooltip>
|
||||
</ToggleGroupItem>
|
||||
),
|
||||
)}
|
||||
</ToggleGroup>
|
||||
</PolicyConfigBox>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { ReactElement, useEffect } from 'react';
|
||||
import { Input, Tooltip } from '@/components/v2';
|
||||
import { InfoCircledIcon } from '@radix-ui/react-icons';
|
||||
import { useConfigurationHelper } from '../form-helper';
|
||||
import { PolicyConfigBox } from '../policy-config-box';
|
||||
|
||||
export const PolicyStringInputConfig = (props: {
|
||||
rule: string;
|
||||
title: string;
|
||||
propertyName: string;
|
||||
defaultValue: string;
|
||||
tooltip?: ReactElement;
|
||||
}): ReactElement => {
|
||||
const { config, setConfig, getConfigValue } = useConfigurationHelper().ruleConfig(props.rule);
|
||||
const currentValue = getConfigValue<string | undefined>(props.propertyName);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) {
|
||||
setConfig(props.propertyName, props.defaultValue);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PolicyConfigBox
|
||||
title={
|
||||
<div className="items-center flex">
|
||||
<div>{props.title}</div>
|
||||
{props.tooltip ? (
|
||||
<Tooltip content={props.tooltip}>
|
||||
<InfoCircledIcon className="ml-2 text-orange-500" />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
className="h-5"
|
||||
id={`${props.rule}_${props.propertyName}`}
|
||||
value={currentValue || ''}
|
||||
onChange={e => setConfig(props.propertyName, e.target.value)}
|
||||
/>
|
||||
</PolicyConfigBox>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { ReactElement } from 'react';
|
||||
import { CheckboxProps, Indicator, Root } from '@radix-ui/react-checkbox';
|
||||
import { CheckIcon } from '@radix-ui/react-icons';
|
||||
|
||||
export const Checkbox = (props: CheckboxProps): ReactElement => {
|
||||
return (
|
||||
|
|
@ -16,13 +17,15 @@ export const Checkbox = (props: CheckboxProps): ReactElement => {
|
|||
border-orange-500
|
||||
bg-gray-800
|
||||
text-orange-500
|
||||
focus:ring
|
||||
disabled:cursor-not-allowed
|
||||
disabled:border-gray-900
|
||||
hover:border-orange-700
|
||||
"
|
||||
{...props}
|
||||
>
|
||||
<Indicator className="h-3.5 w-3.5 rounded-sm bg-current" />
|
||||
<Indicator className="h-full w-full bg-current items-center flex justify-center">
|
||||
<CheckIcon className="text-black" />
|
||||
</Indicator>
|
||||
</Root>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,50 +1,14 @@
|
|||
import React from 'react';
|
||||
import Select, { StylesConfig } from 'react-select';
|
||||
import clsx from 'clsx';
|
||||
import Select, { components } from 'react-select';
|
||||
import CreatableSelect from 'react-select/creatable';
|
||||
import { CaretDownIcon, CrossCircledIcon } from '@radix-ui/react-icons';
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const styles: StylesConfig = {
|
||||
control: styles => ({
|
||||
...styles,
|
||||
backgroundColor: '#24272E',
|
||||
borderWidth: 1,
|
||||
borderColor: '#5f6169',
|
||||
}),
|
||||
multiValue: styles => ({
|
||||
...styles,
|
||||
backgroundColor: '#4B5563',
|
||||
color: '#fff',
|
||||
}),
|
||||
multiValueLabel: styles => ({
|
||||
...styles,
|
||||
color: '#fff',
|
||||
}),
|
||||
multiValueRemove: styles => ({
|
||||
...styles,
|
||||
color: '#6B7280',
|
||||
':hover': {
|
||||
backgroundColor: '#6B7280',
|
||||
color: '#fff',
|
||||
},
|
||||
}),
|
||||
option: styles => ({
|
||||
...styles,
|
||||
color: '#fff',
|
||||
fontSize: '14px',
|
||||
backgroundColor: '#24272E',
|
||||
':hover': {
|
||||
backgroundColor: '#5f6169',
|
||||
},
|
||||
}),
|
||||
menu: styles => ({
|
||||
...styles,
|
||||
backgroundColor: '#24272E',
|
||||
}),
|
||||
};
|
||||
|
||||
export function Combobox(
|
||||
props: React.PropsWithoutRef<{
|
||||
name: string;
|
||||
|
|
@ -54,16 +18,53 @@ export function Combobox(
|
|||
onBlur: (el: unknown) => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
creatable?: boolean;
|
||||
}>,
|
||||
) {
|
||||
const Comp = props.creatable ? CreatableSelect : Select;
|
||||
|
||||
return (
|
||||
<Select
|
||||
<Comp
|
||||
name={props.name}
|
||||
className={props.className}
|
||||
components={{
|
||||
ClearIndicator: compProps => (
|
||||
<components.ClearIndicator {...compProps}>
|
||||
<CrossCircledIcon />
|
||||
</components.ClearIndicator>
|
||||
),
|
||||
DropdownIndicator: compProps => (
|
||||
<components.DropdownIndicator {...compProps}>
|
||||
<CaretDownIcon />
|
||||
</components.DropdownIndicator>
|
||||
),
|
||||
NoOptionsMessage: compProps => (
|
||||
<components.NoOptionsMessage {...compProps}>
|
||||
<div className="text-gray-500 text-xs">
|
||||
{props.creatable ? 'Start typing to add values' : 'No options'}
|
||||
</div>
|
||||
</components.NoOptionsMessage>
|
||||
),
|
||||
}}
|
||||
classNames={{
|
||||
control: () => clsx('bg-gray-800 border-gray-800 hover:border-orange-800 shadow-none'),
|
||||
valueContainer: () => clsx('bg-gray-800 rounded-xl'),
|
||||
indicatorsContainer: () => clsx('bg-gray-800 rounded-xl'),
|
||||
container: () => clsx('bg-gray-800 rounded-xl shadow-lg text-sm'),
|
||||
menu: () => clsx('bg-gray-800 rounded-xl shadow-lg text-xs'),
|
||||
menuList: () => clsx('bg-gray-800 rounded-lg text-xs'),
|
||||
option: () => clsx('bg-gray-800 hover:bg-gray-700 text-xs cursor-pointer'),
|
||||
placeholder: () => clsx('text-gray-500 text-xs'),
|
||||
input: () => clsx('text-gray-500 text-xs'),
|
||||
multiValue: () => clsx('text-gray-500 text-xs bg-gray-200 font-bold'),
|
||||
multiValueRemove: () => clsx('text-gray-500 text-xs hover:bg-gray-300 hover:text-gray-700'),
|
||||
}}
|
||||
closeMenuOnSelect={false}
|
||||
value={props.value}
|
||||
isMulti
|
||||
options={props.options}
|
||||
styles={styles}
|
||||
placeholder={props.name}
|
||||
onChange={props.onChange as any}
|
||||
isDisabled={props.disabled}
|
||||
onBlur={props.onBlur}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { ReactElement } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { getDocsUrl } from '@/lib/docs-url';
|
||||
import { ExclamationTriangleIcon, ExternalLinkIcon, InfoCircledIcon } from '@radix-ui/react-icons';
|
||||
import { Link } from './link';
|
||||
|
|
@ -17,13 +19,30 @@ export const DocsNote = ({ children, warn }: { warn?: boolean; children: React.R
|
|||
);
|
||||
};
|
||||
|
||||
export const DocsLink = ({ href, children }: { href: string; children: React.ReactNode }) => {
|
||||
const fullUrl = getDocsUrl(href) || 'https://docs.graphql-hive.com/';
|
||||
export const DocsLink = ({
|
||||
href,
|
||||
children,
|
||||
icon,
|
||||
className,
|
||||
}: {
|
||||
href: string;
|
||||
icon?: ReactElement;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
const fullUrl = href.startsWith('http')
|
||||
? href
|
||||
: getDocsUrl(href) || 'https://docs.graphql-hive.com/';
|
||||
|
||||
return (
|
||||
<Link className="text-orange-500" href={fullUrl} target="_blank" rel="noreferrer">
|
||||
<Link
|
||||
className={clsx('text-orange-500', className)}
|
||||
href={fullUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{children}
|
||||
<ExternalLinkIcon className="inline pl-1" />
|
||||
{icon ?? <ExternalLinkIcon className="inline pl-1" />}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export function RadixSelect<T extends string>({
|
|||
name,
|
||||
placeholder,
|
||||
}: {
|
||||
multiple?: boolean;
|
||||
className?: string;
|
||||
options: SelectOption[];
|
||||
onChange: (value: T) => void;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export const ToggleGroup = ({ className, children, ...props }: RootProps) => {
|
|||
</Root>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToggleGroupItem = ({ className, children, ...props }: ItemProps) => {
|
||||
return (
|
||||
<Item
|
||||
|
|
|
|||
|
|
@ -22,10 +22,11 @@ function Wrapper({
|
|||
'radix-side-right:animate-slide-left-fade',
|
||||
'radix-side-bottom:animate-slide-up-fade',
|
||||
'radix-side-left:animate-slide-right-fade',
|
||||
'rounded-sm bg-white p-2 text-xs font-normal text-black shadow',
|
||||
'rounded-lg bg-gray-800 p-4 text-xs font-normal text-white shadow',
|
||||
contentProps.className,
|
||||
)}
|
||||
>
|
||||
<T.Arrow className="fill-current text-white" />
|
||||
<T.Arrow className="fill-current text-black" />
|
||||
{content}
|
||||
</T.Content>
|
||||
</T.Root>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ const colors = require('tailwindcss/colors');
|
|||
module.exports = {
|
||||
darkMode: 'class',
|
||||
content: ['./{pages,src}/**/*.ts{,x}'],
|
||||
important: true,
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 9 KiB |
|
|
@ -2,5 +2,6 @@
|
|||
"schema-registry": "Schema Registry",
|
||||
"usage-reporting": "Usage Reporting and Monitoring",
|
||||
"high-availability-cdn": "High-Availability CDN",
|
||||
"schema-policy": "Schema Policies",
|
||||
"laboratory": "Laboratory"
|
||||
}
|
||||
|
|
|
|||
126
packages/web/docs/src/pages/docs/features/schema-policy.mdx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import NextImage from 'next/image'
|
||||
import { Callout } from '@theguild/components'
|
||||
import cliPolicyErrorImage from '../../../../public/docs/pages/features/policy/policy-cli-error.png'
|
||||
import cliPolicyWarningImage from '../../../../public/docs/pages/features/policy/policy-cli-warning.png'
|
||||
import policyOverrideConfigImage from '../../../../public/docs/pages/features/policy/policy-override-config.png'
|
||||
import policyOverrideSeverityImage from '../../../../public/docs/pages/features/policy/policy-override-severity.png'
|
||||
import policyOverviewImage from '../../../../public/docs/pages/features/policy/policy-overview.png'
|
||||
import policyRulesConfigImage from '../../../../public/docs/pages/features/policy/policy-rules-config.png'
|
||||
import policySeverityImage from '../../../../public/docs/pages/features/policy/policy-severity.png'
|
||||
|
||||
# Schema Policies
|
||||
|
||||
In addition to providing basic schema registry functionality, Hive allows you to define policies for
|
||||
schema checks performed on your schemas. These policies can enforce semantic constraints or best
|
||||
practices on the GraphQL schema, such as requiring a description on all fields or a specific naming
|
||||
convention for all types.
|
||||
|
||||
Schema Policies are integrated into the schema `check` process of Hive. Every rule from the ruleset
|
||||
have its own configuration and adjustments, and can have its severity:
|
||||
|
||||
- `warning` will display a emit a warning
|
||||
- `error` will reject and fail the schema check
|
||||
|
||||
You can define policies on either the organization or project level. Organization-level policies can
|
||||
be overridden on the project level, if allowed by the organization settings.
|
||||
|
||||
## Managing Policy Rules
|
||||
|
||||
Hive is using [GraphQL-ESLint powerful set of rules](https://the-guild.dev/graphql/eslint/rules) to
|
||||
run checks on your schemas.
|
||||
|
||||
To find the list of all available rules, go to your **organization** or **project** page, and choose
|
||||
**Policy** tab. You will see the list of all available rules, with the ability to enable/disable
|
||||
rule by using the corresponding checkbox.
|
||||
|
||||
<NextImage
|
||||
alt="Schema Policy Overview"
|
||||
src={policyOverviewImage}
|
||||
className="mt-6 max-w-2xl drop-shadow-md"
|
||||
/>
|
||||
|
||||
Once a rule is activated, you may configure the following options:
|
||||
|
||||
### Rule Severity
|
||||
|
||||
You can set the severity of the rule to `warning` or `error`. The default severity is `warning`.
|
||||
|
||||
<NextImage
|
||||
alt="Schema Policy Severity"
|
||||
src={policySeverityImage}
|
||||
className="w-sm mt-6 drop-shadow-md"
|
||||
/>
|
||||
|
||||
A rule defined with `warning` severity will emit a **warning**, but will not fail the schema check.
|
||||
|
||||
A rule defined with `error` severity will emit an **error**, and will fail the schema check.
|
||||
|
||||
### Per-rule configuration
|
||||
|
||||
Every rule activated in the policy can be configured with the corresponding options. Some rules
|
||||
might only have the severity option, but some rule can be configured with additional options to
|
||||
ensure flexibility.
|
||||
|
||||
<NextImage
|
||||
alt="Policy Per-rule Configuration"
|
||||
src={policyRulesConfigImage}
|
||||
className="mt-6 max-w-2xl drop-shadow-md"
|
||||
/>
|
||||
|
||||
## Policies Hierarchy and Overrides
|
||||
|
||||
When a schema is checked, the **target** policy and rules are calculated based on the following:
|
||||
|
||||
1. Organization-level policies (if any)
|
||||
2. Project-level policies (if any)
|
||||
|
||||
Every rule that is defined on the project level overrides the corresponding rule defined on the
|
||||
organization level (including all configurations and severity). **Per-rule configuration is not
|
||||
being merged**.
|
||||
|
||||
When a policy is defined for the organization, you'll see an indicator on the project page, near
|
||||
every rule that you override:
|
||||
|
||||
<NextImage
|
||||
alt="Override Policy Configuration"
|
||||
src={policyOverrideConfigImage}
|
||||
className="mt-6 max-w-2xl drop-shadow-md"
|
||||
/>
|
||||
|
||||
In addition to the ability to override rules configuration or severity, you maybe disable rules that
|
||||
were defined at the organization level by setting the rule severity to `off`.
|
||||
|
||||
<NextImage
|
||||
alt="Override Policy Severity"
|
||||
src={policyOverrideSeverityImage}
|
||||
className="mt-6 max-w-2xl drop-shadow-md"
|
||||
/>
|
||||
|
||||
### Prevening overrides
|
||||
|
||||
If you wish to prevent overrides of your organization-level policies, you can do so by unchecking
|
||||
the `Allow projects to override or disable rules` checkbox under your organization's **Policy**
|
||||
page.
|
||||
|
||||
<Callout type="info">
|
||||
Disallowing projects to override the organization-level policies will prevent the projects from
|
||||
editing the project-level policy. If a previous policy has been set for the project, it will be
|
||||
ignored during schema checks.
|
||||
</Callout>
|
||||
|
||||
## Integration with Hive CLI
|
||||
|
||||
Schema Policies is fully integrated into the Hive CLI, and you'll see both warnings and error coming
|
||||
from the schema policies:
|
||||
|
||||
<NextImage
|
||||
alt="Hive CLI and Policy Warnings"
|
||||
src={cliPolicyWarningImage}
|
||||
className="mt-6 max-w-2xl drop-shadow-md"
|
||||
/>
|
||||
|
||||
<NextImage
|
||||
alt="Hive CLI and Policy Errors"
|
||||
src={cliPolicyErrorImage}
|
||||
className="mt-6 max-w-2xl drop-shadow-md"
|
||||
/>
|
||||
|
|
@ -461,5 +461,6 @@ powerful features of Hive:
|
|||
- [Conditional Breaking Changes](/docs/management/targets#conditional-breaking-changes)
|
||||
- [Alerts and Notifications](/docs/management/projects#alerts-and-notifications)
|
||||
- [CI/CD Integration](/docs/integrations/ci-cd)
|
||||
- [Schema Policies](/docs/features/schema-policy)
|
||||
|
||||
</Steps>
|
||||
|
|
|
|||