Merge pull request #18187 from sennyeya/visibility-secret
feat(config-loader): Make children of `secret` visibility parents `secret` themselves.
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
---
|
||||
'@backstage/config-loader': minor
|
||||
---
|
||||
|
||||
Adds a new `deepVisibility` schema keyword that sets child visibility recursively to the defined value, respecting preexisting values or child `deepVisibility`.
|
||||
|
||||
Example usage:
|
||||
|
||||
```ts
|
||||
export interface Config {
|
||||
/**
|
||||
* Enforces a default of `secret` instead of `backend` for this object.
|
||||
* @deepVisibility secret
|
||||
*/
|
||||
mySecretProperty: {
|
||||
type: 'object';
|
||||
properties: {
|
||||
secretValue: {
|
||||
type: 'string';
|
||||
};
|
||||
|
||||
verySecretProperty: {
|
||||
type: 'string';
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Example of a schema that would not be allowed:
|
||||
|
||||
```ts
|
||||
export interface Config {
|
||||
/**
|
||||
* Set the top level property to secret, enforcing a default of `secret` instead of `backend` for this object.
|
||||
* @deepVisibility secret
|
||||
*/
|
||||
mySecretProperty: {
|
||||
type: 'object';
|
||||
properties: {
|
||||
frontendUrl: {
|
||||
/**
|
||||
* We can NOT override the visibility to reveal a property to the front end.
|
||||
* @visibility frontend
|
||||
*/
|
||||
type: 'string';
|
||||
};
|
||||
|
||||
verySecretProperty: {
|
||||
type: 'string';
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
@@ -190,7 +190,7 @@ async function compileTsSchemas(paths: string[]) {
|
||||
// This enables the use of these tags in TSDoc comments
|
||||
{
|
||||
required: true,
|
||||
validationKeywords: ['visibility', 'deprecated'],
|
||||
validationKeywords: ['visibility', 'deepVisibility', 'deprecated'],
|
||||
},
|
||||
[path.split(sep).join('/')], // Unix paths are expected for all OSes here
|
||||
) as JsonObject | null;
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('compileConfigSchemas', () => {
|
||||
c: { type: 'string' },
|
||||
d: {
|
||||
type: 'array',
|
||||
visibility: 'secret',
|
||||
visibility: 'frontend',
|
||||
items: { type: 'string', visibility: 'frontend' },
|
||||
},
|
||||
},
|
||||
@@ -86,7 +86,7 @@ describe('compileConfigSchemas', () => {
|
||||
c: { type: 'string', visibility: 'backend' },
|
||||
d: {
|
||||
type: 'array',
|
||||
visibility: 'secret',
|
||||
visibility: 'frontend',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
@@ -102,7 +102,7 @@ describe('compileConfigSchemas', () => {
|
||||
Object.entries({
|
||||
'/a': 'frontend',
|
||||
'/b': 'secret',
|
||||
'/d': 'secret',
|
||||
'/d': 'frontend',
|
||||
'/d/0': 'frontend',
|
||||
}),
|
||||
),
|
||||
@@ -110,7 +110,7 @@ describe('compileConfigSchemas', () => {
|
||||
Object.entries({
|
||||
'/properties/a': 'frontend',
|
||||
'/properties/b': 'secret',
|
||||
'/properties/d': 'secret',
|
||||
'/properties/d': 'frontend',
|
||||
'/properties/d/items': 'frontend',
|
||||
}),
|
||||
),
|
||||
@@ -231,3 +231,209 @@ describe('compileConfigSchemas', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deepVisibility', () => {
|
||||
it('should pass secret visibility to children, but respect existing backend/secret visibility', () => {
|
||||
const validate = compileConfigSchemas([
|
||||
{
|
||||
path: 'a1',
|
||||
value: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: { type: 'string', visibility: 'backend' },
|
||||
b: { type: 'string', visibility: 'backend' },
|
||||
c: { type: 'string' },
|
||||
d: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'a2',
|
||||
value: {
|
||||
type: 'object',
|
||||
deepVisibility: 'secret',
|
||||
properties: {
|
||||
a: { type: 'string' },
|
||||
b: { type: 'string', visibility: 'secret' },
|
||||
c: { type: 'string', visibility: 'backend' },
|
||||
d: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
validate([
|
||||
{
|
||||
data: { a: 'a', b: 'b', c: 'c', d: ['d'] },
|
||||
context: 'test',
|
||||
},
|
||||
]),
|
||||
).toEqual({
|
||||
visibilityByDataPath: new Map(
|
||||
Object.entries({
|
||||
'': 'secret',
|
||||
'/b': 'secret',
|
||||
'/d': 'secret',
|
||||
'/d/0': 'secret',
|
||||
}),
|
||||
),
|
||||
visibilityBySchemaPath: new Map(
|
||||
Object.entries({
|
||||
'': 'secret',
|
||||
'/properties/b': 'secret',
|
||||
'/properties/d': 'secret',
|
||||
'/properties/d/items': 'secret',
|
||||
}),
|
||||
),
|
||||
deprecationByDataPath: new Map(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass secret visibility to children, but throws when overriding with frontend visibility', () => {
|
||||
expect(() =>
|
||||
compileConfigSchemas([
|
||||
{
|
||||
path: 'a1',
|
||||
value: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: { type: 'string', visibility: 'frontend' },
|
||||
b: { type: 'string', visibility: 'backend' },
|
||||
c: { type: 'string' },
|
||||
d: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'a2',
|
||||
value: {
|
||||
type: 'object',
|
||||
deepVisibility: 'secret',
|
||||
visibility: 'secret',
|
||||
properties: {
|
||||
a: { type: 'string' },
|
||||
b: { type: 'string', visibility: 'secret' },
|
||||
c: { type: 'string', visibility: 'frontend' },
|
||||
d: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toThrow(
|
||||
"Config schema visibility is both 'frontend' and 'secret' for /properties/a",
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when children have a different deepVisibility', () => {
|
||||
expect(() =>
|
||||
compileConfigSchemas([
|
||||
{
|
||||
path: 'a1',
|
||||
value: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: { type: 'string' },
|
||||
},
|
||||
},
|
||||
b: { type: 'string', visibility: 'backend' },
|
||||
c: { type: 'string' },
|
||||
d: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'a2',
|
||||
value: {
|
||||
type: 'object',
|
||||
deepVisibility: 'secret',
|
||||
properties: {
|
||||
a: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: { type: 'string', visibility: 'frontend' },
|
||||
},
|
||||
},
|
||||
b: { type: 'string', visibility: 'secret' },
|
||||
c: { type: 'string', visibility: 'backend' },
|
||||
d: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toThrow(
|
||||
`Config schema visibility is both 'frontend' and 'secret' for /properties/a`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when ancestor and children have a different deepVisibility', () => {
|
||||
expect(() =>
|
||||
compileConfigSchemas([
|
||||
{
|
||||
path: 'a1',
|
||||
value: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: { type: 'string' },
|
||||
},
|
||||
},
|
||||
b: { type: 'string', visibility: 'backend' },
|
||||
c: { type: 'string' },
|
||||
d: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'a2',
|
||||
value: {
|
||||
type: 'object',
|
||||
deepVisibility: 'secret',
|
||||
properties: {
|
||||
a: {
|
||||
type: 'object',
|
||||
deepVisibility: 'frontend',
|
||||
properties: {
|
||||
a: { type: 'string', visibility: 'frontend' },
|
||||
},
|
||||
},
|
||||
b: { type: 'string', visibility: 'secret' },
|
||||
c: { type: 'string', visibility: 'backend' },
|
||||
d: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toThrow(
|
||||
`Config schema visibility is both 'frontend' and 'secret' for /properties/a`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,6 +74,20 @@ export function compileConfigSchemas(
|
||||
};
|
||||
},
|
||||
})
|
||||
.addKeyword({
|
||||
keyword: 'deepVisibility',
|
||||
metaSchema: {
|
||||
type: 'string',
|
||||
/**
|
||||
* Disallow 'backend' deepVisibility to prevent cases of permission escaping.
|
||||
*
|
||||
* Something like:
|
||||
* - deepVisibility secret -> backend -> frontend.
|
||||
* - deepVisibility secret -> backend -> visibility frontend.
|
||||
*/
|
||||
enum: ['frontend', 'secret'],
|
||||
},
|
||||
})
|
||||
.removeKeyword('deprecated') // remove `deprecated` keyword so that we can implement our own compiler
|
||||
.addKeyword({
|
||||
keyword: 'deprecated',
|
||||
@@ -101,17 +115,54 @@ export function compileConfigSchemas(
|
||||
|
||||
const merged = mergeConfigSchemas(schemas.map(_ => _.value));
|
||||
|
||||
if (options?.noUndeclaredProperties) {
|
||||
traverse(merged, (schema: SchemaObject) => {
|
||||
/**
|
||||
* The `additionalProperties` key can only be applied to `type: object` in the JSON
|
||||
* schema.
|
||||
*/
|
||||
if (schema?.type === 'object') {
|
||||
schema.additionalProperties ||= false;
|
||||
traverse(
|
||||
merged,
|
||||
(
|
||||
schema: SchemaObject,
|
||||
jsonPtr: string,
|
||||
_1: any,
|
||||
_2: any,
|
||||
_3?: any,
|
||||
parentSchema?: SchemaObject,
|
||||
) => {
|
||||
// Inherit parent deepVisibility if we don't define one ourselves.
|
||||
if (parentSchema?.deepVisibility) {
|
||||
schema.deepVisibility ??= parentSchema?.deepVisibility;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Apply deep visibility to self.
|
||||
if (schema?.deepVisibility) {
|
||||
// This runs before we compile the AJV so visibilityByDataPath has the
|
||||
// correct data.
|
||||
schema.visibility ??= schema.deepVisibility;
|
||||
|
||||
if (parentSchema) {
|
||||
/**
|
||||
* Validate that we're not trying to override a child's visibility
|
||||
* by setting the parent deep visibility.
|
||||
*/
|
||||
const values = [schema.visibility, parentSchema.visibility];
|
||||
const hasFrontend = values.some(e => e === 'frontend');
|
||||
const hasSecret = values.some(e => e === 'secret');
|
||||
if (hasFrontend && hasSecret) {
|
||||
throw new Error(
|
||||
`Config schema visibility is both 'frontend' and 'secret' for ${jsonPtr}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.noUndeclaredProperties) {
|
||||
/**
|
||||
* The `additionalProperties` key can only be applied to `type: object` in the JSON
|
||||
* schema.
|
||||
*/
|
||||
if (schema?.type === 'object') {
|
||||
schema.additionalProperties ||= false;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const validate = ajv.compile(merged);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user