Skip to content

Commit c20fda2

Browse files
committed
feat: improve error handling of missing resource input when required
1 parent a127321 commit c20fda2

File tree

12 files changed

+267
-57
lines changed

12 files changed

+267
-57
lines changed

integration-tests/tests/cli/schema.spec.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/* eslint-disable no-process-env */
22
import { createHash } from 'node:crypto';
33
import { ProjectType } from 'testkit/gql/graphql';
4+
import * as GraphQLSchema from 'testkit/gql/graphql';
45
import { createCLI, schemaCheck, schemaPublish } from '../../testkit/cli';
56
import { cliOutputSnapshotSerializer } from '../../testkit/cli-snapshot-serializer';
67
import { initSeed } from '../../testkit/seed';
@@ -497,3 +498,139 @@ test('schema:check gives correct error message for missing `--service` name flag
497498
- Missing service name
498499
`);
499500
});
501+
502+
test('schema:check without `--target` flag fails for organization access token', async ({
503+
expect,
504+
}) => {
505+
const { createOrg } = await initSeed().createOwner();
506+
const { createOrganizationAccessToken } = await createOrg();
507+
const privateKey = await createOrganizationAccessToken({
508+
permissions: ['schemaCheck:create', 'project:describe'],
509+
resources: {
510+
mode: GraphQLSchema.ResourceAssignmentMode.All,
511+
},
512+
});
513+
514+
await expect(
515+
schemaCheck([
516+
'--registry.accessToken',
517+
privateKey,
518+
'--author',
519+
'Kamil',
520+
'fixtures/init-schema.graphql',
521+
]),
522+
).rejects.toMatchInlineSnapshot(`
523+
:::::::::::::::: CLI FAILURE OUTPUT :::::::::::::::
524+
exitCode------------------------------------------:
525+
2
526+
stderr--------------------------------------------:
527+
› Error: A target is required to perform the action. Please provide the
528+
› "--target" flag. [109]
529+
› > See https://__URL__ for
530+
› a complete list of error codes and recommended fixes.
531+
› To disable this message set HIVE_NO_ERROR_TIP=1
532+
stdout--------------------------------------------:
533+
__NONE__
534+
`);
535+
});
536+
537+
test('schema:check with `--target` flag succeeds for organization access token', async ({
538+
expect,
539+
}) => {
540+
const { createOrg } = await initSeed().createOwner();
541+
const { createOrganizationAccessToken, createProject, organization } = await createOrg();
542+
const { project, target } = await createProject();
543+
const privateKey = await createOrganizationAccessToken({
544+
permissions: ['schemaCheck:create', 'project:describe'],
545+
resources: {
546+
mode: GraphQLSchema.ResourceAssignmentMode.All,
547+
},
548+
});
549+
550+
await expect(
551+
schemaCheck([
552+
'--registry.accessToken',
553+
privateKey,
554+
'--author',
555+
'Kamil',
556+
'--target',
557+
`${organization.slug}/${project.slug}/${target.slug}`,
558+
'fixtures/init-schema.graphql',
559+
]),
560+
).resolves.toMatchInlineSnapshot(`
561+
:::::::::::::::: CLI SUCCESS OUTPUT :::::::::::::::::
562+
563+
stdout--------------------------------------------:
564+
✔ Schema registry is empty, nothing to compare your schema with.
565+
View full report:
566+
http://__URL__
567+
`);
568+
});
569+
570+
test('schema:publish without `--target` flag fails for organization access token', async ({
571+
expect,
572+
}) => {
573+
const { createOrg } = await initSeed().createOwner();
574+
const { createOrganizationAccessToken } = await createOrg();
575+
const privateKey = await createOrganizationAccessToken({
576+
permissions: ['project:describe', 'schemaVersion:publish'],
577+
resources: {
578+
mode: GraphQLSchema.ResourceAssignmentMode.All,
579+
},
580+
});
581+
582+
await expect(
583+
schemaPublish([
584+
'--registry.accessToken',
585+
privateKey,
586+
'--author',
587+
'Kamil',
588+
589+
'fixtures/init-schema.graphql',
590+
]),
591+
).rejects.toMatchInlineSnapshot(`
592+
:::::::::::::::: CLI FAILURE OUTPUT :::::::::::::::
593+
exitCode------------------------------------------:
594+
2
595+
stderr--------------------------------------------:
596+
› Error: A target is required to perform the action. Please provide the
597+
› "--target" flag. [109]
598+
› > See https://__URL__ for
599+
› a complete list of error codes and recommended fixes.
600+
› To disable this message set HIVE_NO_ERROR_TIP=1
601+
stdout--------------------------------------------:
602+
__NONE__
603+
`);
604+
});
605+
606+
test('schema:publish with `--target` flag succeeds for organization access token', async ({
607+
expect,
608+
}) => {
609+
const { createOrg } = await initSeed().createOwner();
610+
const { createOrganizationAccessToken, organization, createProject } = await createOrg();
611+
const { project, target } = await createProject();
612+
const privateKey = await createOrganizationAccessToken({
613+
permissions: ['project:describe', 'schemaVersion:publish'],
614+
resources: {
615+
mode: GraphQLSchema.ResourceAssignmentMode.All,
616+
},
617+
});
618+
619+
await expect(
620+
schemaPublish([
621+
'--registry.accessToken',
622+
privateKey,
623+
'--author',
624+
'Kamil',
625+
'--target',
626+
`${organization.slug}/${project.slug}/${target.slug}`,
627+
'fixtures/init-schema.graphql',
628+
]),
629+
).resolves.toMatchInlineSnapshot(`
630+
:::::::::::::::: CLI SUCCESS OUTPUT :::::::::::::::::
631+
632+
stdout--------------------------------------------:
633+
✔ Published initial schema.
634+
ℹ Available at http://__URL__
635+
`);
636+
});

packages/libraries/cli/src/base-command.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
InvalidRegistryTokenError,
1515
isAggregateError,
1616
MissingArgumentsError,
17+
MissingTargetError,
1718
NetworkError,
1819
} from './helpers/errors';
1920
import { Texture } from './helpers/texture/texture';
@@ -262,6 +263,9 @@ export default abstract class BaseCommand<T extends typeof Command> extends Comm
262263
}
263264

264265
if (jsonData.errors && jsonData.errors.length > 0) {
266+
if (jsonData.errors[0].extensions?.code === 'ERR_MISSING_TARGET') {
267+
throw new MissingTargetError();
268+
}
265269
if (jsonData.errors[0].message === 'Invalid token provided') {
266270
throw new InvalidRegistryTokenError();
267271
}

packages/libraries/cli/src/helpers/errors.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,16 @@ export class MissingCdnEndpointError extends HiveCLIError {
137137
}
138138
}
139139

140+
export class MissingTargetError extends HiveCLIError {
141+
constructor() {
142+
super(
143+
ExitCode.ERROR,
144+
errorCode(ErrorCategory.GENERIC, 9),
145+
`A target is required to perform the action. Please provide the "--target" flag.`,
146+
);
147+
}
148+
}
149+
140150
export class MissingEnvironmentError extends HiveCLIError {
141151
constructor(...requiredVars: Array<[string, string]>) {
142152
const varsStr = requiredVars.map(a => `\t${a[0]} \t${a[1]}`).join('\n');

packages/services/api/src/modules/app-deployments/providers/app-deployments-manager.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Injectable, Scope } from 'graphql-modules';
22
import * as GraphQLSchema from '../../../__generated__/types';
33
import { Target } from '../../../shared/entities';
44
import { batch } from '../../../shared/helpers';
5-
import { InsufficientPermissionError, Session } from '../../auth/lib/authz';
5+
import { Session } from '../../auth/lib/authz';
66
import { IdTranslator } from '../../shared/providers/id-translator';
77
import { Logger } from '../../shared/providers/logger';
88
import { TargetManager } from '../../target/providers/target-manager';
@@ -64,11 +64,12 @@ export class AppDeploymentsManager {
6464
}) {
6565
const selector = await this.idTranslator.resolveTargetReference({
6666
reference: args.reference,
67-
onError() {
68-
throw new InsufficientPermissionError('appDeployment:create');
69-
},
7067
});
7168

69+
if (!selector) {
70+
this.session.raise('appDeployment:create');
71+
}
72+
7273
await this.session.assertPerformAction({
7374
action: 'appDeployment:create',
7475
organizationId: selector.organizationId,
@@ -100,11 +101,12 @@ export class AppDeploymentsManager {
100101
}) {
101102
const selector = await this.idTranslator.resolveTargetReference({
102103
reference: args.reference,
103-
onError() {
104-
throw new InsufficientPermissionError('appDeployment:create');
105-
},
106104
});
107105

106+
if (!selector) {
107+
this.session.raise('appDeployment:create');
108+
}
109+
108110
await this.session.assertPerformAction({
109111
action: 'appDeployment:create',
110112
organizationId: selector.organizationId,
@@ -134,11 +136,12 @@ export class AppDeploymentsManager {
134136
}) {
135137
const selector = await this.idTranslator.resolveTargetReference({
136138
reference: args.reference,
137-
onError() {
138-
throw new InsufficientPermissionError('appDeployment:publish');
139-
},
140139
});
141140

141+
if (!selector) {
142+
this.session.raise('appDeployment:publish');
143+
}
144+
142145
await this.session.assertPerformAction({
143146
action: 'appDeployment:publish',
144147
organizationId: selector.organizationId,

packages/services/api/src/modules/auth/lib/authz.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ export abstract class Session {
7272
organizationId: string,
7373
): Promise<Array<AuthorizationPolicyStatement>> | Array<AuthorizationPolicyStatement>;
7474

75+
abstract readonly id: string;
76+
7577
/** Retrieve the current viewer. Implementations of the session need to implement this function */
7678
public getViewer(): Promise<User> {
7779
throw new AccessError('Authorization token is missing', 'UNAUTHENTICATED');
@@ -130,6 +132,15 @@ export abstract class Session {
130132
return await result;
131133
}
132134

135+
/**
136+
* Raise an insufficient permission error.
137+
* Useful in situations where a resource can not be identified and it should be treated
138+
* as having insufficient permissions.
139+
*/
140+
public raise<TAction extends keyof typeof actionDefinitions>(action: TAction): never {
141+
throw new InsufficientPermissionError(action);
142+
}
143+
133144
/**
134145
* Check whether a session is allowed to perform a specific action.
135146
* Throws a AccessError if the action is not allowed.
@@ -185,7 +196,7 @@ export abstract class Session {
185196
args.organizationId,
186197
args.params,
187198
);
188-
throw new InsufficientPermissionError(args.action);
199+
this.raise(args.action);
189200
} else {
190201
isAllowed = true;
191202
}
@@ -201,7 +212,7 @@ export abstract class Session {
201212
args.params,
202213
);
203214

204-
throw new InsufficientPermissionError(args.action);
215+
this.raise(args.action);
205216
}
206217
}
207218

packages/services/api/src/modules/auth/lib/organization-access-token-strategy.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@ function hashToken(token: string) {
1313
export class OrganizationAccessTokenSession extends Session {
1414
public readonly organizationId: string;
1515
private policies: Array<AuthorizationPolicyStatement>;
16+
readonly id: string;
1617

1718
constructor(
1819
args: {
20+
id: string;
1921
organizationId: string;
2022
policies: Array<AuthorizationPolicyStatement>;
2123
},
@@ -24,6 +26,7 @@ export class OrganizationAccessTokenSession extends Session {
2426
},
2527
) {
2628
super({ logger: deps.logger });
29+
this.id = args.id;
2730
this.organizationId = args.organizationId;
2831
this.policies = args.policies;
2932
}
@@ -105,6 +108,7 @@ export class OrganizationAccessTokenStrategy extends AuthNStrategy<OrganizationA
105108

106109
return new OrganizationAccessTokenSession(
107110
{
111+
id: organizationAccessToken.id,
108112
organizationId: organizationAccessToken.organizationId,
109113
policies: organizationAccessToken.authorizationPolicyStatements,
110114
},

packages/services/api/src/modules/auth/lib/supertokens-strategy.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ export class SuperTokensCookieBasedSession extends Session {
2525
this.storage = deps.storage;
2626
}
2727

28+
get id(): string {
29+
return this.superTokensUserId;
30+
}
31+
2832
protected async loadPolicyStatementsForOrganization(
2933
organizationId: string,
3034
): Promise<Array<AuthorizationPolicyStatement>> {

packages/services/api/src/modules/auth/lib/target-access-token-strategy.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ export class TargetAccessTokenSession extends Session {
4343
return this.policies;
4444
}
4545

46+
get id(): string {
47+
return this.token;
48+
}
49+
4650
public getLegacySelector() {
4751
return {
4852
token: this.token,

packages/services/api/src/modules/schema/providers/schema-manager.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,12 @@ import {
2222
ProjectType,
2323
Target,
2424
} from '../../../shared/entities';
25-
import { HiveError } from '../../../shared/errors';
25+
import { HiveError, MissingTargetError } from '../../../shared/errors';
2626
import { atomic, cache, stringifySelector } from '../../../shared/helpers';
2727
import { isUUID } from '../../../shared/is-uuid';
2828
import { parseGraphQLSource } from '../../../shared/schema';
2929
import { InsufficientPermissionError, Session } from '../../auth/lib/authz';
30+
import { TargetAccessTokenSession } from '../../auth/lib/target-access-token-strategy';
3031
import { GitHubIntegrationManager } from '../../integrations/providers/github-integration-manager';
3132
import { ProjectManager } from '../../project/providers/project-manager';
3233
import { CryptoProvider } from '../../shared/providers/crypto';
@@ -123,11 +124,12 @@ export class SchemaManager {
123124

124125
const selector = await this.idTranslator.resolveTargetReference({
125126
reference: input.target ?? null,
126-
onError() {
127-
throw new InsufficientPermissionError('schema:compose');
128-
},
129127
});
130128

129+
if (!selector) {
130+
this.session.raise('schema:compose');
131+
}
132+
131133
trace.getActiveSpan()?.setAttributes({
132134
'hive.organization.id': selector.organizationId,
133135
'hive.target.id': selector.targetId,
@@ -1002,11 +1004,12 @@ export class SchemaManager {
10021004
}) {
10031005
const selector = await this.idTranslator.resolveTargetReference({
10041006
reference: args.target,
1005-
onError() {
1006-
throw new InsufficientPermissionError('project:describe');
1007-
},
10081007
});
10091008

1009+
if (!selector) {
1010+
this.session.raise('project:describe');
1011+
}
1012+
10101013
this.logger.debug('Fetch schema version by action id. (args=%o)', {
10111014
projectId: selector.projectId,
10121015
targetId: selector.targetId,

0 commit comments

Comments
 (0)