Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sharing resource initiated by the app #51

Merged
merged 13 commits into from
Apr 17, 2023
Merged
Show file tree
Hide file tree
Changes from 10 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
1 change: 1 addition & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
. "$(dirname "$0")/_/husky.sh"

npx pretty-quick --staged
npx eslint packages/*/src
39 changes: 20 additions & 19 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,31 @@
"./packages/*"
],
"devDependencies": {
"@jest/globals": "^29.0.3 ",
"@types/jest": "^29.0.3",
"@typescript-eslint/eslint-plugin": "^4.26.0",
"@typescript-eslint/parser": "^4.26.0",
"eslint": "^7.27.0",
"eslint-config-airbnb-base": "^14.2.1",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-import": "^2",
"@jest/globals": "^29.5.0 ",
"@types/jest": "^29.5.0",
"@typescript-eslint/eslint-plugin": "^5.58.0",
"@typescript-eslint/parser": "^5.58.0",
"eslint": "^8.38.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-monorepo": "^0.3.2",
"husky": "^7.0.1",
"jest": "^29.0.3",
"jest-mock": "^29.0.3",
"jest-rdf": "^1.7.0",
"lerna": "^4.0.0",
"prettier": "^2.3.2",
"pretty-quick": "^3.1.1",
"rimraf": "^3.0.2",
"ts-jest": "^29.0.1",
"typescript": "^4.8.3"
"husky": "^8.0.3",
"jest": "^29.5.0",
"jest-mock": "^29.5.0",
"jest-rdf": "^1.8.0",
"lerna": "^6.6.1",
"prettier": "^2.8.7",
"pretty-quick": "^3.1.3",
"rimraf": "^5.0.0",
"ts-jest": "^29.1.0",
"typescript": "^5.0.4"
},
"scripts": {
"prepare": "husky install"
},
"volta": {
"node": "16.14.0"
"node": "18.15.0",
"yarn": "1.22.19"
}
}
6 changes: 3 additions & 3 deletions packages/application/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"test": "test"
},
"scripts": {
"build": "npm run clean && npm run build:cjs && npm run build:mjs && sh fixup.sh",
"build": "npm run build:cjs && npm run build:mjs && sh fixup.sh",
"build:cjs": "tsc -b tsconfig-cjs.json",
"build:mjs": "tsc -b tsconfig-mjs.json",
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
Expand All @@ -49,10 +49,10 @@
"@janeirodigital/interop-data-model": "^1.0.0-rc.17",
"@janeirodigital/interop-namespaces": "^1.0.0-rc.17",
"@janeirodigital/interop-utils": "^1.0.0-rc.17",
"n3": "^1.11.0"
"n3": "^1.16.4"
},
"devDependencies": {
"@janeirodigital/interop-test-utils": "^1.0.0-rc.17",
"@rdfjs/types": "^1.0.1"
"@rdfjs/types": "^1.1.0"
}
}
21 changes: 14 additions & 7 deletions packages/application/src/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,26 @@ export class Application {
if (applicationRegistrationIri) {
this.hasApplicationRegistration = await this.factory.readable.applicationRegistration(applicationRegistrationIri);
}
if (!applicationRegistrationIri || !this.hasApplicationRegistration?.hasAccessGrant.granted) {
this.authorizationRedirectEndpoint = await discoverAuthorizationRedirectEndpoint(
this.authorizationAgentIri,
this.rawFetch
);
}
this.authorizationRedirectEndpoint = await discoverAuthorizationRedirectEndpoint(
this.authorizationAgentIri,
this.rawFetch
);
}

// eslint-disable-next-line consistent-return
get authorizationRedirectUri(): string | undefined {
if (this.authorizationRedirectEndpoint) {
return `${this.authorizationRedirectEndpoint}?client_id=${this.applicationId}`;
return `${this.authorizationRedirectEndpoint}?client_id=${encodeURIComponent(this.applicationId)}`;
}
}

getShareUri(resourceIri: string): string | undefined {
if (this.authorizationRedirectEndpoint) {
return `${this.authorizationRedirectEndpoint}?resource=${encodeURIComponent(
resourceIri
)}&client_id=${encodeURIComponent(this.applicationId)}`;
}
return undefined;
}

static async build(
Expand Down
31 changes: 17 additions & 14 deletions packages/application/test/application-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,6 @@ describe('applicatrion registration exists', () => {
expect(app.hasApplicationRegistration).toBeInstanceOf(ReadableApplicationRegistration);
});

test('should have authorizationRedirectEndpoint undefined', async () => {
mocked.mockResolvedValueOnce(await statelessFetch(webId)).mockResolvedValueOnce(responseMock);
const app = await Application.build(webId, applicationId, { fetch: mocked, randomUUID });
expect(app.authorizationRedirectEndpoint).toBeUndefined();
});

test('should have authorizationRedirectUri undefined', async () => {
mocked.mockResolvedValueOnce(await statelessFetch(webId)).mockResolvedValueOnce(responseMock);
const app = await Application.build(webId, applicationId, { fetch: mocked, randomUUID });
expect(app.authorizationRedirectUri).toBeUndefined();
});

test('should have dataOwners getter', async () => {
mocked.mockResolvedValueOnce(await statelessFetch(webId)).mockResolvedValueOnce(responseMock);
const app = await Application.build(webId, applicationId, { fetch: mocked, randomUUID });
Expand All @@ -51,7 +39,7 @@ describe('applicatrion registration exists', () => {
}
});
});
describe('applicatrion registration does not exist', () => {
describe('discovery helpers', () => {
const expectedRedirectUriBase = 'https://auth.example/authorize';
const mocked = jest.fn(statelessFetch);
const responseMock = { ok: true, headers: { get: (): null => null } } as unknown as RdfResponse;
Expand All @@ -72,12 +60,27 @@ describe('applicatrion registration does not exist', () => {
test('should have correct authorizationRedirectUri', async () => {
mocked.mockResolvedValueOnce(await statelessFetch(webId)).mockResolvedValueOnce(responseMock);
const app = await Application.build(webId, applicationId, { fetch: mocked, randomUUID });
expect(app.authorizationRedirectUri).toBe(`${expectedRedirectUriBase}?client_id=${applicationId}`);
expect(app.authorizationRedirectUri).toBe(
`${expectedRedirectUriBase}?client_id=${encodeURIComponent(applicationId)}`
);
});

test('should have dataOwners getter returning an empty array', async () => {
mocked.mockResolvedValueOnce(await statelessFetch(webId)).mockResolvedValueOnce(responseMock);
const app = await Application.build(webId, applicationId, { fetch: mocked, randomUUID });
expect(app.dataOwners).toHaveLength(0);
});

// TODO: refactor to be independent from order of query parameters
test('should provide shareUri', async () => {
mocked.mockResolvedValueOnce(await statelessFetch(webId)).mockResolvedValueOnce(responseMock);
const app = await Application.build(webId, applicationId, { fetch: mocked, randomUUID });
const resourceUri = 'some-resource-uri';
const shareUri = app.getShareUri(resourceUri);
expect(shareUri).toBe(
`${expectedRedirectUriBase}?resource=${encodeURIComponent(resourceUri)}&client_id=${encodeURIComponent(
applicationId
)}`
);
});
});
6 changes: 3 additions & 3 deletions packages/authorization-agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"test": "test"
},
"scripts": {
"build": "npm run clean && npm run build:cjs && npm run build:mjs && sh fixup.sh",
"build": "npm run build:cjs && npm run build:mjs && sh fixup.sh",
"build:cjs": "tsc -b tsconfig-cjs.json",
"build:mjs": "tsc -b tsconfig-mjs.json",
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
Expand All @@ -49,10 +49,10 @@
"@janeirodigital/interop-data-model": "^1.0.0-rc.17",
"@janeirodigital/interop-namespaces": "^1.0.0-rc.17",
"@janeirodigital/interop-utils": "^1.0.0-rc.17",
"n3": "^1.11.0"
"n3": "^1.16.4"
},
"devDependencies": {
"@janeirodigital/interop-test-utils": "^1.0.0-rc.17",
"@rdfjs/types": "^1.0.1"
"@rdfjs/types": "^1.1.0"
}
}
168 changes: 163 additions & 5 deletions packages/authorization-agent/src/authorization-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,55 @@ import {
CRUDSocialAgentRegistration,
CRUDApplicationRegistration,
ImmutableDataGrant,
ReadableWebIdProfile
ReadableWebIdProfile,
ReadableDataAuthorization,
ReadableDataRegistration
} from '@janeirodigital/interop-data-model';
import { WhatwgFetch, RdfFetch, fetchWrapper } from '@janeirodigital/interop-utils';
import { AccessAuthorizationStructure, generateAuthorization } from './authorization';
import { INTEROP } from '@janeirodigital/interop-namespaces';
import { WhatwgFetch, RdfFetch, fetchWrapper, iterable2array } from '@janeirodigital/interop-utils';
import {
AccessAuthorizationStructure,
generateAuthorization,
GrantedAuthorization,
NestedDataAuthorizationData
} from './authorization';

interface AuthorizationAgentDependencies {
fetch: WhatwgFetch;
randomUUID(): string;
}

export interface AgentWithAccess {
agent: string;
dataAuthorization: string;
accessMode: string[];
}

// TODO: duplicats ShareAuthorization from api-messages (sai-impl-service)
export type ShareDataInstanceStructure = {
applicationId: string;
resource: string;
accessMode: string[];
children: {
shapeTree: string;
accessMode: string[];
}[];
agents: string[];
};

// TODO: adjust if registrations are not / nested in the registry
function registryOfRegistration(dataRegistrationIri: string): string {
return `${dataRegistrationIri.split('/').slice(0, -2).join('/')}/`;
}

function formatAgentWithAccess(dataAuthorization: ReadableDataAuthorization): AgentWithAccess {
return {
agent: dataAuthorization.grantee,
dataAuthorization: dataAuthorization.iri,
accessMode: dataAuthorization.accessMode
};
}

export class AuthorizationAgent {
factory: AuthorizationAgentFactory;

Expand Down Expand Up @@ -55,6 +94,18 @@ export class AuthorizationAgent {
return this.registrySet.hasAgentRegistry.findSocialAgentRegistration(iri);
}

public async findDataRegistration(dataRegistryIri: string, shapeTree: string): Promise<ReadableDataRegistration> {
const dataRegistry = this.registrySet.hasDataRegistry.find((registry) => registry.iri === dataRegistryIri);
let dataRegistration: ReadableDataRegistration;
for await (const registration of dataRegistry.registrations) {
if (registration.registeredShapeTree === shapeTree) {
dataRegistration = registration;
break;
}
}
return dataRegistration;
}

private async bootstrap(): Promise<void> {
this.webIdProfile = await this.factory.readable.webIdProfile(this.webId);
this.registrySet = await this.factory.crud.registrySet(this.webIdProfile.hasRegistrySet);
Expand All @@ -80,7 +131,8 @@ export class AuthorizationAgent {
* TODO: reuse existing Data Authorizations wherever possible - see Data Authorization tests
*/
public async recordAccessAuthorization(
authorization: AccessAuthorizationStructure
authorization: AccessAuthorizationStructure,
extendIfExists = false
): Promise<ReadableAccessAuthorization> {
// create data authorizations

Expand All @@ -90,7 +142,8 @@ export class AuthorizationAgent {
this.webId,
this.registrySet.hasAuthorizationRegistry,
this.agentId,
this.factory
this.factory,
extendIfExists
);
}

Expand Down Expand Up @@ -130,4 +183,109 @@ export class AuthorizationAgent {
affectedAuthorizations.map(async (accessAuthorization) => this.generateAccessGrant(accessAuthorization.iri))
);
}

public async findSocialAgentsWithAccess(dataInstanceIri: string): Promise<AgentWithAccess[]> {
const dataInstance = await this.factory.readable.dataInstance(dataInstanceIri);
const shapeTree = dataInstance.dataRegistration.registeredShapeTree;
const agentsWithAccess: AgentWithAccess[] = [];
for await (const accessAuthorization of this.accessAuthorizations) {
const dataAuthorization = (
await iterable2array<ReadableDataAuthorization>(accessAuthorization.dataAuthorizations)
).find((autorization) => autorization.registeredShapeTree === shapeTree);
// eslint-disable-next-line no-continue
if (!dataAuthorization) continue;

switch (dataAuthorization.scopeOfAuthorization) {
case INTEROP.All.value:
agentsWithAccess.push(formatAgentWithAccess(dataAuthorization));
break;
case INTEROP.AllFromAgent.value:
// TODO: rethink for delegated sharing, e.g. Alice shares project owned by ACME
if (dataAuthorization.dataOwner === this.webId) {
agentsWithAccess.push(formatAgentWithAccess(dataAuthorization));
}
break;
case INTEROP.AllFromRegistry.value:
if (dataAuthorization.hasDataRegistration === dataInstance.dataRegistration.iri) {
agentsWithAccess.push(formatAgentWithAccess(dataAuthorization));
}
break;
case INTEROP.SelectedInstances.value:
if (
dataAuthorization.hasDataRegistration === dataInstance.dataRegistration.iri &&
dataAuthorization.hasDataInstance.includes(dataInstanceIri)
) {
agentsWithAccess.push(formatAgentWithAccess(dataAuthorization));
}
break;
default:
throw new Error(
`encountered incorect Data Authorization with scope:${dataAuthorization.scopeOfAuthorization}`
);
}
}
const socialAgentsWithAccess: AgentWithAccess[] = [];
for await (const registration of this.socialAgentRegistrations) {
const socialAgentWithAccess = agentsWithAccess.find(({ agent }) => agent === registration.registeredAgent);
if (socialAgentWithAccess) {
socialAgentsWithAccess.push(socialAgentWithAccess);
}
}
return socialAgentsWithAccess;
}

/*
* Authorizes access to a specific Data Instance to multiple agents
* TODO: support delegated authorization
*/
public async shareDataInstance(details: ShareDataInstanceStructure): Promise<void> {
// ensure owner doesn't grant acces for oneself
// TODO: reconsider for TrustedGrants grantees, compare with data instance owner instead
const requestedAgents = details.agents.filter((agent) => agent !== this.webId);
// filter out agents who already have access
// TODO: do we need to adjust once we handle access modes? or require separate operation for such change
const agentsWithAccess = (await this.findSocialAgentsWithAccess(details.resource)).map((obj) => obj.agent);
const agents = requestedAgents.filter((agent) => !agentsWithAccess.includes(agent));

// TODO: ensure all agents have social agent registrations, throw error

const dataInstance = await this.factory.readable.dataInstance(details.resource);
await Promise.all(
agents.map(async (agent) => {
const dataAuthorization: NestedDataAuthorizationData = {
grantee: agent,
registeredShapeTree: dataInstance.dataRegistration.registeredShapeTree,
scopeOfAuthorization: INTEROP.SelectedInstances.value,
dataOwner: this.webId, // TODO: delegated authorizations and trusted agents
hasDataRegistration: dataInstance.dataRegistration.iri,
accessMode: details.accessMode,
hasDataInstance: [dataInstance.iri],
children: await Promise.all(
details.children.map(async (child) => ({
grantee: agent,
registeredShapeTree: child.shapeTree,
scopeOfAuthorization: INTEROP.Inherited.value,
dataOwner: this.webId, // TODO: delegated authorizations and trusted agents
hasDataRegistration: (
await this.findDataRegistration(
registryOfRegistration(dataInstance.dataRegistration.iri),
child.shapeTree
)
).iri,
accessMode: child.accessMode
}))
)
};
const authorization: GrantedAuthorization = {
grantee: agent,
granted: true,
dataAuthorizations: [dataAuthorization]
};
const accessAuthorization = await this.recordAccessAuthorization(authorization, true);

// TODO: does it belong here?
await this.generateAccessGrant(accessAuthorization.iri);
})
);
}
}
Loading