Skip to content

Commit 7969ce1

Browse files
committed
fix: authenticate protected source Pod access
1 parent 26609f9 commit 7969ce1

14 files changed

Lines changed: 241 additions & 185 deletions

‎README.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -136,13 +136,13 @@ Send a JSON message containing a query, a processing type, and a client identifi
136136

137137
## Credentials
138138

139-
### Normal live operation
139+
Heimdall can access both public and protected Solid streams. Authentication is a property of the source Pod or stream deployment, not of whether a query is `live` or `historical+live`.
140140

141-
Normal `live` operation, including the Sensors-style live evaluation path, does not load source-Pod client credentials.
141+
For a protected source, copy [source-pod-credentials.example.json](./config/source-pod-credentials.example.json) to `config/source-pod-credentials.local.json`, fill it locally, and keep it untracked. Alternatively, set `HEIMDALL_SOURCE_POD_CREDENTIALS_FILE` to a local credential-file path. Each key is a source stream URL or a Pod URL prefix; the most-specific matching entry is selected for that source. The entry contains a CSS client-credential `id`, `secret`, and `idp`.
142142

143-
### historical+live
143+
When configured, Heimdall reuses the resulting authenticated session for relevant source operations: Type Index lookup, LDES metadata and historical retrieval, notification inbox and subscription-server discovery, notification subscription creation, and notification-event retrieval. When no matching source credentials are configured, these operations use ordinary unauthenticated HTTP, so public streams do not need a credentials file. A protected resource with no usable credentials reports the underlying authorization failure; an invalid configured entry reports a configuration error.
144144

145-
`historical+live` requires a client-credential entry for each source stream. Copy [source-pod-credentials.example.json](./config/source-pod-credentials.example.json) to `config/source-pod-credentials.local.json`, fill it locally, and keep it untracked. Alternatively, set `HEIMDALL_SOURCE_POD_CREDENTIALS_FILE` to a local credential-file path.
145+
This does not assert that the Sensors evaluation used authentication; its deployment-specific authorization setup is not established by this repository.
146146

147147
### Legacy aggregation-Pod functionality
148148

‎src/config/privateCredentials.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as fs from 'fs';
22
import * as os from 'os';
33
import * as path from 'path';
4-
import { aggregationPodAccountFile, loadAggregationPodCredentials, loadSourcePodCredentials } from './privateCredentials';
4+
import { aggregationPodAccountFile, loadAggregationPodCredentials, loadOptionalSourcePodCredentials, loadSourcePodCredentials } from './privateCredentials';
55

66
describe('private credential configuration', () => {
77
const previousSource = process.env.HEIMDALL_SOURCE_POD_CREDENTIALS_FILE;
@@ -36,4 +36,9 @@ describe('private credential configuration', () => {
3636
delete process.env.HEIMDALL_SOURCE_POD_CREDENTIALS_FILE;
3737
expect(() => loadSourcePodCredentials()).toThrow('Missing source-Pod credentials');
3838
});
39+
40+
it('treats absent source credentials as an optional public-stream configuration', () => {
41+
delete process.env.HEIMDALL_SOURCE_POD_CREDENTIALS_FILE;
42+
expect(loadOptionalSourcePodCredentials()).toEqual({});
43+
});
3944
});

‎src/config/privateCredentials.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ export function loadSourcePodCredentials(): SourcePodCredentials {
1616
return loadJson('HEIMDALL_SOURCE_POD_CREDENTIALS_FILE', 'config/source-pod-credentials.local.json', 'source-Pod credentials');
1717
}
1818

19+
/**
20+
* Source-Pod authentication is optional: public streams must not require a
21+
* local credential file. Invalid configured files still fail loudly.
22+
*/
23+
export function loadOptionalSourcePodCredentials(): SourcePodCredentials {
24+
const filePath = process.env.HEIMDALL_SOURCE_POD_CREDENTIALS_FILE || path.resolve(process.cwd(), 'config/source-pod-credentials.local.json');
25+
return fs.existsSync(filePath) ? JSON.parse(fs.readFileSync(filePath, 'utf8')) as SourcePodCredentials : {};
26+
}
27+
1928
export function loadAggregationPodCredentials(): AggregationPodCredentials {
2029
return loadJson('HEIMDALL_AGGREGATION_POD_CREDENTIALS_FILE', 'config/aggregation-pod-credentials.local.json', 'aggregation-Pod credentials');
2130
}

‎src/service/heimdall/DecentralizedFileStreamer.ts‎

Lines changed: 10 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,12 @@ const websocketConnection = require('websocket').connection;
88
const WebSocketClient = require('websocket').client;
99
import { Quad } from "n3";
1010
import { QuadWithID } from "../../utils/Types";
11-
import { session_with_credentials } from "../../utils/authentication/CSSAuthentication";
1211
import { readMembersRateLimited } from "../../utils/ldes-in-ldp/EventSource";
1312
import { RateLimitedLDPCommunication } from "rate-limited-ldp-communication";
1413
import { hash_string_md5 } from "../../utils/Util";
1514
import { TREE } from "@treecg/ldes-snapshot";
16-
import { Session } from "@inrupt/solid-client-authn-node";
1715
import { create_subscription, extract_ldp_inbox, extract_subscription_server } from "../../utils/notifications/Util";
16+
import { SourcePodAccess } from './SourcePodAccess';
1817
import * as HEIMDALL_SETUP from '../../config/heimdall_setup.json';
1918
import { resolveHeimdallSetupConfig, resolveHeimdallWebSocketUrl } from "../../config/heimdallConfig";
2019
import { HEIMDALL_WEBSOCKET_PROTOCOL } from "../../server/websocketProtocols";
@@ -32,7 +31,7 @@ export class DecentralizedFileStreamer {
3231
public ldes!: LDESinLDP;
3332
public comunica_engine: QueryEngine;
3433
public communication: Promise<SolidCommunication | LDPCommunication | RateLimitedLDPCommunication>;
35-
public session: any;
34+
private readonly sourcePodAccess: SourcePodAccess;
3635
public observation_array: any[];
3736
public query: string
3837
public query_hash: string;
@@ -43,17 +42,18 @@ export class DecentralizedFileStreamer {
4342
/**
4443
* Creates an instance of DecentralizedFileStreamer.
4544
* @param {string} ldes_stream - The LDES stream URL.
46-
* @param {session_credentials} session_credentials - The credentials of the Solid Pod.
45+
* @param {SourcePodAccess} sourcePodAccess - Shared optional authenticated access for the source Pod.
4746
* @param {Date} from_date - The start date of the events to be read from the Solid Pod.
4847
* @param {Date} to_date - The end date of the events to be read from the Solid Pod.
4948
* @param {RSPEngine} rsp_engine - The RSP Engine.
5049
* @param {string} query - The query to be executed.
5150
* @param {*} logger - The logger object.
5251
* @memberof DecentralizedFileStreamer
5352
*/
54-
constructor(ldes_stream: string, session_credentials: session_credentials, from_date: Date, to_date: Date, rsp_engine: RSPEngine, query: string, logger: any) {
53+
constructor(ldes_stream: string, sourcePodAccess: SourcePodAccess, from_date: Date, to_date: Date, rsp_engine: RSPEngine, query: string, logger: any) {
5554
this.ldes_stream = ldes_stream;
56-
this.communication = this.get_communication(session_credentials);
55+
this.sourcePodAccess = sourcePodAccess;
56+
this.communication = this.sourcePodAccess.communicationFor(ldes_stream);
5757
this.from_date = from_date;
5858
this.to_date = to_date;
5959
this.query = query;
@@ -78,16 +78,6 @@ export class DecentralizedFileStreamer {
7878
* @returns {Promise<SolidCommunication | LDPCommunication>} - The communication object with the Solid Pod.
7979
* @memberof DecentralizedFileStreamer
8080
*/
81-
public async get_communication(credentials: session_credentials) {
82-
const session = await this.get_session(credentials);
83-
if (session) {
84-
return new SolidCommunication(session);
85-
}
86-
else {
87-
return new LDPCommunication();
88-
}
89-
}
90-
9181
/**
9282
* Adding the events which might have been added between
9383
* the start of the file streamer and the start of the websocket
@@ -240,12 +230,13 @@ export class DecentralizedFileStreamer {
240230
async subscribing_latest_events(stream_name: RDFStream | undefined) {
241231
if (stream_name !== undefined) {
242232
console.log(`Subscribing to the latest events of the stream`, stream_name);
243-
const inbox = await extract_ldp_inbox(this.ldes_stream);
233+
const sourceFetch = await this.sourcePodAccess.fetchFor(this.ldes_stream);
234+
const inbox = await extract_ldp_inbox(this.ldes_stream, undefined, sourceFetch);
244235
if (inbox !== undefined) {
245-
const subscription_server = await extract_subscription_server(inbox);
236+
const subscription_server = await extract_subscription_server(inbox, undefined, sourceFetch);
246237
if (subscription_server !== undefined) {
247238
const server = subscription_server.location;
248-
const response_subscription = await create_subscription(server, inbox);
239+
const response_subscription = await create_subscription(server, inbox, undefined, sourceFetch);
249240
if (response_subscription) {
250241
this.logger.info(`The subscription has been succesful.`);
251242
} else {
@@ -408,15 +399,6 @@ export class DecentralizedFileStreamer {
408399
get_file_streamer_start_time() {
409400
return this.file_streamer_start_time;
410401
}
411-
/**
412-
* Get the session with the credentials.
413-
* @param {session_credentials} credentials - The credentials of the solid pod for which you can generate an authenticated session to communicated to the Solid Pod's LDP.
414-
* @returns {Promise<Session>} - The authenticated session.
415-
* @memberof DecentralizedFileStreamer
416-
*/
417-
async get_session(credentials: session_credentials): Promise<Session> {
418-
return await session_with_credentials(credentials);
419-
}
420402
/**
421403
* Send a message to Heimdall's WebSocket server.
422404
* @static

‎src/service/heimdall/HeimdallInstantiator.ts‎

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ import { v4 as uuidv4 } from 'uuid';
55
import { EventEmitter } from "events";
66
import { BindingsWithTimestamp } from "../../utils/Types";
77
import { hash_string_md5 } from "../../utils/Util";
8-
import { Credentials, aggregation_object } from "../../utils/Types";
8+
import { aggregation_object } from "../../utils/Types";
99
import { SharedStreamRegistry } from './SharedStreamRegistry';
1010
import { N3ReasonerService } from "../reasoner/N3Reasoner";
1111
import { HEIMDALL_WEBSOCKET_PROTOCOL } from "../../server/websocketProtocols";
1212
import { MetricWriter } from '../../evaluation/MetricWriter';
1313
import * as HEIMDALL_SETUP from '../../config/heimdall_setup.json';
1414
import { resolveHeimdallWebSocketUrl } from '../../config/heimdallConfig';
15-
import { loadSourcePodCredentials } from '../../config/privateCredentials';
15+
import { SourcePodAccess } from './SourcePodAccess';
1616
const WebSocketClient = require('websocket').client;
1717
const websocketConnection = require('websocket').connection;
1818
const parser = new RSPQLParser();
@@ -37,6 +37,7 @@ export class HeimdallInstantiator {
3737
private readonly client_id?: string;
3838
private readonly ready_promise: Promise<void>;
3939
private readonly sharedStreamRegistry?: SharedStreamRegistry;
40+
private readonly sourcePodAccess: SourcePodAccess;
4041
/**
4142
* Creates an instance of HeimdallInstantiator.
4243
* @param {string} query - The RSPQL query.
@@ -48,14 +49,15 @@ export class HeimdallInstantiator {
4849
* @param {string} rules - The rules for the query.
4950
* @memberof HeimdallInstantiator
5051
*/
51-
public constructor(query: string, from_timestamp: number, to_timestamp: number, logger: any, query_type: string, event_emitter: any, rules: string = '', metric_writer: MetricWriter = new MetricWriter(), client_id?: string, sharedStreamRegistry?: SharedStreamRegistry) {
52+
public constructor(query: string, from_timestamp: number, to_timestamp: number, logger: any, query_type: string, event_emitter: any, rules: string = '', metric_writer: MetricWriter = new MetricWriter(), client_id?: string, sharedStreamRegistry?: SharedStreamRegistry, sourcePodAccess: SourcePodAccess = sharedStreamRegistry?.sourcePodAccess || new SourcePodAccess()) {
5253
this.query = query;
5354
this.rules = rules;
5455
this.logger = logger;
5556
this.event_emitter = event_emitter;
5657
this.metric_writer = metric_writer;
5758
this.client_id = client_id;
5859
this.sharedStreamRegistry = sharedStreamRegistry;
60+
this.sourcePodAccess = sourcePodAccess;
5961
this.hash_string = hash_string_md5(query);
6062
this.rsp_engine = new RSPEngine(query, {
6163
// This delay is evaluation configuration for the 4 Hz experiment only;
@@ -94,9 +96,7 @@ export class HeimdallInstantiator {
9496
if (this.stream_array.length !== 0) {
9597
if (query_type === 'historical+live') {
9698
for (const stream of this.stream_array) {
97-
const session_credentials = this.get_session_credentials(stream);
98-
this.logger.info({ query_hashed }, `stream_credentials_retrieved`);
99-
new DecentralizedFileStreamer(stream, session_credentials, this.from_date, this.to_date, this.rsp_engine, this.query, this.logger);
99+
new DecentralizedFileStreamer(stream, this.sourcePodAccess, this.from_date, this.to_date, this.rsp_engine, this.query, this.logger);
100100
}
101101
await this.subscribeRStream();
102102
return true;
@@ -108,7 +108,6 @@ export class HeimdallInstantiator {
108108
// must not precede this query's RStream listener.
109109
await this.subscribeRStream();
110110
for (const stream of this.stream_array) {
111-
this.logger.info({ query_hashed }, `stream_credentials_retrieved`);
112111
if (!this.sharedStreamRegistry) throw new Error('Live processing requires a SharedStreamRegistry');
113112
const rdfStream = this.rsp_engine.getStream(stream);
114113
if (!rdfStream) throw new Error(`RSP engine has no RDF stream for ${stream}`);
@@ -238,17 +237,4 @@ export class HeimdallInstantiator {
238237
});
239238
}
240239
}
241-
/**
242-
* Get the session credentials for the Solid Pod.
243-
* @param {string} stream_name - The name of the stream (i.e the LDES in LDP of the Solid Pod).
244-
* @returns {Credentials} - The session credentials.
245-
* @memberof HeimdallInstantiator
246-
*/
247-
get_session_credentials(stream_name: string) {
248-
const credentials: Credentials = loadSourcePodCredentials();
249-
const session_credentials = credentials[stream_name];
250-
if (!session_credentials) throw new Error(`No source-Pod credentials configured for stream ${stream_name}`);
251-
return session_credentials;
252-
}
253-
254240
}

‎src/service/heimdall/SharedStreamRegistry.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { SharedStreamRegistry } from './SharedStreamRegistry';
22
import { MetricWriter } from '../../evaluation/MetricWriter';
3+
import { SourcePodAccess } from './SourcePodAccess';
34
import * as fs from 'fs';
45
import * as os from 'os';
56
import * as path from 'path';
@@ -84,6 +85,41 @@ describe('SharedStreamRegistry', () => {
8485
expect(registry.consumerCount(streams[0])).toBe(5);
8586
});
8687

88+
it('keeps one protected physical acquisition while its source session is reused', async () => {
89+
const authenticatedFetch = jest.fn();
90+
const createSession = jest.fn().mockResolvedValue({ fetch: authenticatedFetch });
91+
const access = new SourcePodAccess({
92+
'http://example.test/': { id: 'test-id', secret: 'test-secret', idp: 'https://issuer.example/' },
93+
}, createSession);
94+
await access.fetchFor(streams[0]);
95+
const { dependencies } = makeRegistry();
96+
const registry = new SharedStreamRegistry(logger, undefined, dependencies, access);
97+
await Promise.all(['Q1', 'Q2'].map((id) => registry.attach(streams[0], id, fakeRdfStream())));
98+
expect(dependencies.createSubscription).toHaveBeenCalledTimes(1);
99+
expect(createSession).toHaveBeenCalledTimes(1);
100+
});
101+
102+
it('uses one authenticated source fetch for protected live notification discovery and creation', async () => {
103+
const authenticatedFetch = jest.fn()
104+
.mockResolvedValueOnce({ text: jest.fn().mockResolvedValue(`<http://example.test/A/> <http://www.w3.org/ns/ldp#inbox> <inbox/> .`) })
105+
.mockResolvedValueOnce({ headers: new Headers({ link: '<http://example.test/.well-known/solid>; rel="http://www.w3.org/ns/solid/terms#storageDescription"' }) })
106+
.mockResolvedValueOnce({ text: jest.fn().mockResolvedValue(`
107+
<http://example.test/> <http://www.w3.org/ns/solid/notifications#subscription> <http://example.test/subscribe> .
108+
<http://example.test/subscribe> <http://www.w3.org/ns/solid/notifications#channelType> <http://www.w3.org/ns/solid/notifications#WebhookChannel2023> .
109+
`) })
110+
.mockResolvedValueOnce({ ok: true, text: jest.fn().mockResolvedValue('subscription') });
111+
const createSession = jest.fn().mockResolvedValue({ fetch: authenticatedFetch });
112+
const access = new SourcePodAccess({
113+
'http://example.test/': { id: 'test-id', secret: 'test-secret', idp: 'https://issuer.example/' },
114+
}, createSession);
115+
const registry = new SharedStreamRegistry(logger, undefined, { readBucketStrategy: jest.fn().mockResolvedValue('http://example.test/time') }, access);
116+
117+
await registry.attach(streams[0], 'Q1', fakeRdfStream());
118+
expect(createSession).toHaveBeenCalledTimes(1);
119+
expect(authenticatedFetch).toHaveBeenCalledTimes(4);
120+
expect(authenticatedFetch).toHaveBeenNthCalledWith(4, 'http://example.test/subscribe', expect.objectContaining({ method: 'POST' }));
121+
});
122+
87123
it('rolls back a failed creation so a later attach retries', async () => {
88124
const createSubscription = jest.fn().mockRejectedValueOnce(new Error('failed')).mockResolvedValueOnce('subscription');
89125
const { registry } = makeRegistry({ createSubscription });

0 commit comments

Comments
 (0)