Schema policy checks using graphql-eslint (#1730)

This commit is contained in:
Dotan Simha 2023-05-09 11:07:17 +03:00 committed by GitHub
parent c8e18a6a6b
commit 9238a1f915
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
107 changed files with 4845 additions and 340 deletions

View file

@ -0,0 +1,5 @@
---
'@graphql-hive/cli': minor
---
Added support for new warnings feature during `schema:check` commands

View file

@ -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",

View file

@ -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

View file

@ -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 = () => {

View file

@ -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,

View file

@ -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',

View 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();
}

View file

@ -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:

View file

@ -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",

View file

@ -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

View file

@ -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": {

View file

@ -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,

View 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,
],
};

View file

@ -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(
{

View 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)
`);
});
});
});

View 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,
);
});
});
});

View file

@ -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"]

View file

@ -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"
}
}
}

View file

@ -1,4 +1,6 @@
type Query {
foo: Int!
bar: String
test: String
}
# test 3

View file

@ -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;

View file

@ -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

View file

@ -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) {

View file

@ -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(),

View file

@ -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)
);

View file

@ -1 +1,4 @@
ALTER TABLE public.organizations ADD COLUMN feature_flags JSONB
ALTER TABLE
public.organizations
ADD COLUMN
feature_flags JSONB

View file

@ -0,0 +1 @@
raise 'down migration not implemented'

View file

@ -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,

View 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],
});

View 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!]!
}
`;

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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',
);

View 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,
},
};
}
},
},
};

View 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)],
}),
{},
);
}

View file

@ -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 {

View file

@ -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,
},
};
}

View file

@ -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,
},
};

View file

@ -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;
}

View file

@ -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,
},
};

View file

@ -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,
},
};

View file

@ -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,

View file

@ -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));

View file

@ -850,6 +850,7 @@ export const resolvers: SchemaModule.Resolvers = {
},
SchemaChangeConnection: createConnection(),
SchemaErrorConnection: createConnection(),
SchemaWarningConnection: createConnection(),
SchemaCheckSuccess: {
__isTypeOf(obj) {
return obj.valid;

View file

@ -3,6 +3,7 @@ import { gql } from 'graphql-modules';
export default gql`
scalar DateTime
scalar JSON
scalar JSONSchemaObject
scalar SafeInt
type Query {

View file

@ -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()

View file

@ -11,6 +11,7 @@ SafeIntResolver.description = undefined;
export const resolvers: SharedModule.Resolvers = {
DateTime: DateTimeResolver,
JSON: JSONResolver,
JSONSchemaObject: JSONResolver,
SafeInt: SafeIntResolver,
Query: {
noop: () => true,

View file

@ -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];

View file

@ -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[];

View 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.

View 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.

View 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,
},
]
`);
});
});

View 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"
}
}

View 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'];

View file

@ -0,0 +1,7 @@
import { config } from 'dotenv';
config({
debug: true,
});
await import('./index');

View 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;

View 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);
});

View 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'],
});

View 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',
);
}

View 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],
);

View file

@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"target": "ES2020",
"module": "esnext",
"rootDir": "../.."
},
"files": ["src/index.ts"]
}

View file

@ -47,7 +47,7 @@ interface CompositionSuccess {
};
}
export type CompositionErrorSource = 'graphql' | 'composition';
export type CompositionErrorSource = 'graphql' | 'composition' | 'policy';
export interface CompositionFailureError {
message: string;

View file

@ -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"

View file

@ -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` |

View file

@ -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,

View file

@ -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: {

View file

@ -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;

View file

@ -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;

View file

@ -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",

View 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);

View 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);

View file

@ -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>
</>

View file

@ -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>
</>

View 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);
}
},
};
},
};
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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;
}

View file

@ -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>
);
};

View file

@ -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>
);
}

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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}

View file

@ -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>
);
};

View file

@ -21,6 +21,7 @@ export function RadixSelect<T extends string>({
name,
placeholder,
}: {
multiple?: boolean;
className?: string;
options: SelectOption[];
onChange: (value: T) => void;

View file

@ -14,6 +14,7 @@ export const ToggleGroup = ({ className, children, ...props }: RootProps) => {
</Root>
);
};
export const ToggleGroupItem = ({ className, children, ...props }: ItemProps) => {
return (
<Item

View file

@ -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>

View file

@ -3,6 +3,7 @@ const colors = require('tailwindcss/colors');
module.exports = {
darkMode: 'class',
content: ['./{pages,src}/**/*.ts{,x}'],
important: true,
theme: {
container: {
center: true,

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

View file

@ -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"
}

View 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"
/>

View file

@ -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>

Some files were not shown because too many files have changed in this diff Show more