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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/eleven-brooms-happen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-hive/cli': minor
---

Better error handling for missing `--target` option when required.
143 changes: 143 additions & 0 deletions integration-tests/tests/cli/schema.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-disable no-process-env */
import { createHash } from 'node:crypto';
import { ProjectType } from 'testkit/gql/graphql';
import * as GraphQLSchema from 'testkit/gql/graphql';
import { createCLI, schemaCheck, schemaPublish } from '../../testkit/cli';
import { cliOutputSnapshotSerializer } from '../../testkit/cli-snapshot-serializer';
import { initSeed } from '../../testkit/seed';
Expand Down Expand Up @@ -497,3 +498,145 @@ test('schema:check gives correct error message for missing `--service` name flag
- Missing service name
`);
});

test('schema:check without `--target` flag fails for organization access token', async ({
expect,
}) => {
const { createOrg } = await initSeed().createOwner();
const { createOrganizationAccessToken } = await createOrg();
const privateKey = await createOrganizationAccessToken({
permissions: ['schemaCheck:create', 'project:describe'],
resources: {
mode: GraphQLSchema.ResourceAssignmentMode.All,
},
});

await expect(
schemaCheck([
'--registry.accessToken',
privateKey,
'--author',
'Kamil',
'fixtures/init-schema.graphql',
]),
).rejects.toMatchInlineSnapshot(`
:::::::::::::::: CLI FAILURE OUTPUT :::::::::::::::
exitCode------------------------------------------:
2
stderr--------------------------------------------:
› Error: Missing 1 required argument:
› TARGET The target on which the action is performed. This can either be a
› slug following the format "$organizationSlug/$projectSlug/$targetSlug"
› (e.g "the-guild/graphql-hive/staging") or an UUID (e.g.
› "a0f4c605-6541-4350-8cfe-b31f21a4bf80"). [102]
› > See https://__URL__ for
› a complete list of error codes and recommended fixes.
› To disable this message set HIVE_NO_ERROR_TIP=1
stdout--------------------------------------------:
__NONE__
`);
});

test('schema:check with `--target` flag succeeds for organization access token', async ({
expect,
}) => {
const { createOrg } = await initSeed().createOwner();
const { createOrganizationAccessToken, createProject, organization } = await createOrg();
const { project, target } = await createProject();
const privateKey = await createOrganizationAccessToken({
permissions: ['schemaCheck:create', 'project:describe'],
resources: {
mode: GraphQLSchema.ResourceAssignmentMode.All,
},
});

await expect(
schemaCheck([
'--registry.accessToken',
privateKey,
'--author',
'Kamil',
'--target',
`${organization.slug}/${project.slug}/${target.slug}`,
'fixtures/init-schema.graphql',
]),
).resolves.toMatchInlineSnapshot(`
:::::::::::::::: CLI SUCCESS OUTPUT :::::::::::::::::

stdout--------------------------------------------:
✔ Schema registry is empty, nothing to compare your schema with.
View full report:
http://__URL__
`);
});

test('schema:publish without `--target` flag fails for organization access token', async ({
expect,
}) => {
const { createOrg } = await initSeed().createOwner();
const { createOrganizationAccessToken } = await createOrg();
const privateKey = await createOrganizationAccessToken({
permissions: ['project:describe', 'schemaVersion:publish'],
resources: {
mode: GraphQLSchema.ResourceAssignmentMode.All,
},
});

await expect(
schemaPublish([
'--registry.accessToken',
privateKey,
'--author',
'Kamil',

'fixtures/init-schema.graphql',
]),
).rejects.toMatchInlineSnapshot(`
:::::::::::::::: CLI FAILURE OUTPUT :::::::::::::::
exitCode------------------------------------------:
2
stderr--------------------------------------------:
› Error: Missing 1 required argument:
› TARGET The target on which the action is performed. This can either be a
› slug following the format "$organizationSlug/$projectSlug/$targetSlug"
› (e.g "the-guild/graphql-hive/staging") or an UUID (e.g.
› "a0f4c605-6541-4350-8cfe-b31f21a4bf80"). [102]
› > See https://__URL__ for
› a complete list of error codes and recommended fixes.
› To disable this message set HIVE_NO_ERROR_TIP=1
stdout--------------------------------------------:
__NONE__
`);
});

test('schema:publish with `--target` flag succeeds for organization access token', async ({
expect,
}) => {
const { createOrg } = await initSeed().createOwner();
const { createOrganizationAccessToken, organization, createProject } = await createOrg();
const { project, target } = await createProject();
const privateKey = await createOrganizationAccessToken({
permissions: ['project:describe', 'schemaVersion:publish'],
resources: {
mode: GraphQLSchema.ResourceAssignmentMode.All,
},
});

await expect(
schemaPublish([
'--registry.accessToken',
privateKey,
'--author',
'Kamil',
'--target',
`${organization.slug}/${project.slug}/${target.slug}`,
'fixtures/init-schema.graphql',
]),
).resolves.toMatchInlineSnapshot(`
:::::::::::::::: CLI SUCCESS OUTPUT :::::::::::::::::

stdout--------------------------------------------:
✔ Published initial schema.
ℹ Available at http://__URL__
`);
});
8 changes: 8 additions & 0 deletions packages/libraries/cli/src/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,14 @@ export default abstract class BaseCommand<T extends typeof Command> extends Comm
}

if (jsonData.errors && jsonData.errors.length > 0) {
if (jsonData.errors[0].extensions?.code === 'ERR_MISSING_TARGET') {
throw new MissingArgumentsError([
'target',
'The target on which the action is performed.' +
' This can either be a slug following the format "$organizationSlug/$projectSlug/$targetSlug" (e.g "the-guild/graphql-hive/staging")' +
' or an UUID (e.g. "a0f4c605-6541-4350-8cfe-b31f21a4bf80").',
]);
}
if (jsonData.errors[0].message === 'Invalid token provided') {
throw new InvalidRegistryTokenError();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Injectable, Scope } from 'graphql-modules';
import * as GraphQLSchema from '../../../__generated__/types';
import { Target } from '../../../shared/entities';
import { batch } from '../../../shared/helpers';
import { InsufficientPermissionError, Session } from '../../auth/lib/authz';
import { Session } from '../../auth/lib/authz';
import { IdTranslator } from '../../shared/providers/id-translator';
import { Logger } from '../../shared/providers/logger';
import { TargetManager } from '../../target/providers/target-manager';
Expand Down Expand Up @@ -64,11 +64,12 @@ export class AppDeploymentsManager {
}) {
const selector = await this.idTranslator.resolveTargetReference({
reference: args.reference,
onError() {
throw new InsufficientPermissionError('appDeployment:create');
},
});

if (!selector) {
this.session.raise('appDeployment:create');
}

await this.session.assertPerformAction({
action: 'appDeployment:create',
organizationId: selector.organizationId,
Expand Down Expand Up @@ -100,11 +101,12 @@ export class AppDeploymentsManager {
}) {
const selector = await this.idTranslator.resolveTargetReference({
reference: args.reference,
onError() {
throw new InsufficientPermissionError('appDeployment:create');
},
});

if (!selector) {
this.session.raise('appDeployment:create');
}

await this.session.assertPerformAction({
action: 'appDeployment:create',
organizationId: selector.organizationId,
Expand Down Expand Up @@ -134,11 +136,12 @@ export class AppDeploymentsManager {
}) {
const selector = await this.idTranslator.resolveTargetReference({
reference: args.reference,
onError() {
throw new InsufficientPermissionError('appDeployment:publish');
},
});

if (!selector) {
this.session.raise('appDeployment:publish');
}

await this.session.assertPerformAction({
action: 'appDeployment:publish',
organizationId: selector.organizationId,
Expand Down
1 change: 1 addition & 0 deletions packages/services/api/src/modules/auth/lib/authz.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { NoopLogger } from '../../shared/providers/logger';
import { AuthorizationPolicyStatement, Session } from './authz';

class TestSession extends Session {
id = 'test-session';
policyStatements: Array<AuthorizationPolicyStatement>;
constructor(policyStatements: Array<AuthorizationPolicyStatement>) {
super({ logger: new NoopLogger() });
Expand Down
16 changes: 14 additions & 2 deletions packages/services/api/src/modules/auth/lib/authz.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ export abstract class Session {
organizationId: string,
): Promise<Array<AuthorizationPolicyStatement>> | Array<AuthorizationPolicyStatement>;

abstract readonly id: string;

/** Retrieve the current viewer. Implementations of the session need to implement this function */
public getViewer(): Promise<User> {
throw new AccessError('Authorization token is missing', 'UNAUTHENTICATED');
Expand Down Expand Up @@ -130,6 +132,15 @@ export abstract class Session {
return await result;
}

/**
* Raise an insufficient permission error.
* Useful in situations where a resource can not be identified and it should be treated
* as having insufficient permissions.
*/
public raise<TAction extends keyof typeof actionDefinitions>(action: TAction): never {
throw new InsufficientPermissionError(action);
}

/**
* Check whether a session is allowed to perform a specific action.
* Throws a AccessError if the action is not allowed.
Expand Down Expand Up @@ -185,7 +196,7 @@ export abstract class Session {
args.organizationId,
args.params,
);
throw new InsufficientPermissionError(args.action);
this.raise(args.action);
} else {
isAllowed = true;
}
Expand All @@ -201,7 +212,7 @@ export abstract class Session {
args.params,
);

throw new InsufficientPermissionError(args.action);
this.raise(args.action);
}
}

Expand Down Expand Up @@ -486,6 +497,7 @@ class UnauthenticatedSession extends Session {
): Promise<Array<AuthorizationPolicyStatement>> | Array<AuthorizationPolicyStatement> {
return [];
}
id = 'noop';
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ function hashToken(token: string) {
export class OrganizationAccessTokenSession extends Session {
public readonly organizationId: string;
private policies: Array<AuthorizationPolicyStatement>;
readonly id: string;

constructor(
args: {
id: string;
organizationId: string;
policies: Array<AuthorizationPolicyStatement>;
},
Expand All @@ -24,6 +26,7 @@ export class OrganizationAccessTokenSession extends Session {
},
) {
super({ logger: deps.logger });
this.id = args.id;
this.organizationId = args.organizationId;
this.policies = args.policies;
}
Expand Down Expand Up @@ -106,6 +109,7 @@ export class OrganizationAccessTokenStrategy extends AuthNStrategy<OrganizationA

return new OrganizationAccessTokenSession(
{
id: organizationAccessToken.id,
organizationId: organizationAccessToken.organizationId,
policies: organizationAccessToken.authorizationPolicyStatements,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ export class SuperTokensCookieBasedSession extends Session {
this.storage = deps.storage;
}

get id(): string {
return this.superTokensUserId;
}

protected async loadPolicyStatementsForOrganization(
organizationId: string,
): Promise<Array<AuthorizationPolicyStatement>> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ export class TargetAccessTokenSession extends Session {
return this.policies;
}

get id(): string {
return this.token;
}

public getLegacySelector() {
return {
token: this.token,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { HiveError } from '../../../shared/errors';
import { atomic, cache, stringifySelector } from '../../../shared/helpers';
import { isUUID } from '../../../shared/is-uuid';
import { parseGraphQLSource } from '../../../shared/schema';
import { InsufficientPermissionError, Session } from '../../auth/lib/authz';
import { Session } from '../../auth/lib/authz';
import { GitHubIntegrationManager } from '../../integrations/providers/github-integration-manager';
import { ProjectManager } from '../../project/providers/project-manager';
import { CryptoProvider } from '../../shared/providers/crypto';
Expand Down Expand Up @@ -123,11 +123,12 @@ export class SchemaManager {

const selector = await this.idTranslator.resolveTargetReference({
reference: input.target ?? null,
onError() {
throw new InsufficientPermissionError('schema:compose');
},
});

if (!selector) {
this.session.raise('schema:compose');
}

trace.getActiveSpan()?.setAttributes({
'hive.organization.id': selector.organizationId,
'hive.target.id': selector.targetId,
Expand Down Expand Up @@ -1002,11 +1003,12 @@ export class SchemaManager {
}) {
const selector = await this.idTranslator.resolveTargetReference({
reference: args.target,
onError() {
throw new InsufficientPermissionError('project:describe');
},
});

if (!selector) {
this.session.raise('project:describe');
}

this.logger.debug('Fetch schema version by action id. (args=%o)', {
projectId: selector.projectId,
targetId: selector.targetId,
Expand Down
Loading
Loading