Skip to content

Commit aef3b4e

Browse files
committed
Simplify anonymous request handling by removing explicit 'anonymous' string
- Replace explicit 'anonymous' string with empty string for anonymous requests - Simplify checks: use empty string check instead of checking for 'anonymous' - Update LinkService to treat empty userID as anonymous (handle GetUserID errors gracefully) - Update ADR-42 documentation to reflect simplified approach - Remove magic string 'anonymous' from codebase This simplification makes the code cleaner and more intuitive: empty user-id means anonymous request, no need for explicit string.
1 parent e5ed855 commit aef3b4e

File tree

8 files changed

+26
-25
lines changed

8 files changed

+26
-25
lines changed

boundaries/link/internal/usecases/link/get.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,13 @@ func (uc *UC) Get(ctx context.Context, hash string) (*domain.Link, error) {
2727
SAGA_STEP_CHECK_ACCESS = "SAGA_STEP_CHECK_ACCESS"
2828
)
2929

30+
// Get user ID from session metadata (may be empty for anonymous requests)
31+
// If metadata is missing or empty, userID will be empty string (treated as anonymous)
3032
userID, err := session.GetUserID(ctx)
3133
if err != nil {
32-
uc.log.ErrorWithContext(ctx, "failed to get user ID from session",
33-
slog.String("error", err.Error()),
34-
)
35-
36-
return nil, err
34+
// If GetUserID returns error (metadata missing), treat as anonymous request
35+
// This is normal for unauthenticated users accessing public links
36+
userID = ""
3737
}
3838

3939
var resp *domain.Link
@@ -75,11 +75,11 @@ func (uc *UC) Get(ctx context.Context, hash string) (*domain.Link, error) {
7575

7676
// For private links: check via allowlist
7777
// According to ADR-42:
78-
// - If user_id == "anonymous" → ErrPermissionDenied
78+
// - If user_id is empty (anonymous request) → ErrPermissionDenied
7979
// - Get email from Kratos Admin API
8080
// - Check email against allowlist using link.CanBeViewedByEmail(email)
8181

82-
if userID == "" || userID == "anonymous" {
82+
if userID == "" {
8383
return domain.ErrPermissionDenied(nil)
8484
}
8585

boundaries/proxy/src/domain/repositories/ILinkRepository.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export interface ILinkRepository {
99
/**
1010
* Находит ссылку по хешу
1111
* @param hash - хеш ссылки
12-
* @param userId - optional user_id from Kratos session, or "anonymous" if not authenticated
12+
* @param userId - optional user_id from Kratos session, undefined if not authenticated (treated as anonymous)
1313
* @returns Promise с доменной сущностью Link
1414
* @throws LinkNotFoundError если ссылка не найдена или доступ запрещен
1515
*/

boundaries/proxy/src/infrastructure/adapters/ILinkServiceAdapter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export interface ILinkServiceAdapter {
1010
* Получает ссылку по хешу из внешнего сервиса
1111
*
1212
* @param hash - хеш ссылки
13-
* @param userId - optional user_id from Kratos session, or "anonymous" if not authenticated
13+
* @param userId - optional user_id from Kratos session, undefined if not authenticated (treated as anonymous)
1414
* @returns Promise с доменной сущностью Link
1515
* @throws LinkNotFoundError если ссылка не найдена или доступ запрещен
1616
*/

boundaries/proxy/src/infrastructure/adapters/LinkServiceConnectAdapter.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,10 @@ export class LinkServiceConnectAdapter implements ILinkServiceAdapter {
114114
signal: AbortSignal.timeout(this.externalServicesConfig.requestTimeout),
115115
};
116116

117-
// Link Service expects "user-id" in metadata (session_interceptor checks for it)
118-
// Always set header - if userId provided, use it; otherwise pass "anonymous"
119-
// SessionInterceptor will use serviceUserId as fallback if header is missing
120-
const userIdValue = userId && userId !== "anonymous" ? userId : "anonymous";
117+
// Link Service expects "user-id" in metadata
118+
// If userId is not provided (anonymous request), pass empty string (treated as anonymous by LinkService)
119+
// If header is not set at all, SessionInterceptor would set serviceUserId fallback (which we don't want for anonymous requests)
120+
const userIdValue = userId || "";
121121
options.header = {
122122
"user-id": userIdValue,
123123
};

boundaries/proxy/src/infrastructure/adapters/connect/interceptors/SessionInterceptor.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@ const USER_ID_HEADER = "user-id";
88
* Link Service uses this to check private link access via Kratos Admin API.
99
*
1010
* This interceptor sets a default value (serviceUserId) if user-id is not already set
11-
* in the request headers. The actual userId from Kratos session is set via callOptions.header
11+
* in the request headers. This is a fallback for service-to-service calls.
12+
* The actual userId from Kratos session (or empty string for anonymous) is set via callOptions.header
1213
* in LinkServiceConnectAdapter.getLinkByHash().
1314
*
14-
* @param serviceUserId - stable identifier for proxy service account (fallback).
15+
* @param serviceUserId - stable identifier for proxy service account (fallback for service-to-service calls).
1516
*/
1617
export function createSessionInterceptor(serviceUserId: string): Interceptor {
1718
if (!serviceUserId || !serviceUserId.trim()) {

boundaries/proxy/src/infrastructure/http/fastify/controllers/ProxyController.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,11 @@ export class ProxyController {
3535
const hash = new Hash(request.params.hash);
3636

3737
// Extract Kratos session to get user_id for private link access
38-
// According to ADR 42: if no valid session, pass "anonymous"
38+
// If no valid session, userId will be undefined (empty string will be passed to LinkService, treated as anonymous)
3939
const session = await this.kratosSessionExtractor.extractSession(request);
4040
const userId = session.isAuthenticated && session.userId
4141
? session.userId
42-
: "anonymous";
42+
: undefined;
4343

4444
// Call application service with user_id
4545
const result = await this.linkApplicationService.handleRedirect({

boundaries/proxy/src/infrastructure/repositories/LinkServiceRepository.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,10 @@ export class LinkServiceRepository implements ILinkRepository {
3535

3636
async findByHash(hash: Hash, userId?: string | null): Promise<Link> {
3737
// Note: Cache doesn't consider userId, so private links might be cached incorrectly
38-
// For now, we skip cache when userId is provided and not "anonymous" (private link access)
38+
// For now, we skip cache when userId is provided (private link access)
3939
// TODO: Consider cache key that includes userId for private links
4040

41-
if (userId && userId !== "anonymous") {
41+
if (userId) {
4242
// For private links, skip cache and go directly to adapter
4343
this.logger.debug("Cache bypass - fetching private link from adapter", {
4444
hash: hash.value,

docs/ADR/decisions/0042-link-privacy-control.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,10 @@ func (l *Link) CanBeViewedByEmail(email string) bool {
7070

7171
- Reads Kratos session
7272
- Extracts `user_id` from that session
73-
- **If Kratos responds 401 (no valid session) Proxy sends `user_id = "anonymous"` to LinkService**
74-
- Passes `user_id` via gRPC metadata `x-user-id: <kratos_user_id | anonymous>`
73+
- **If no valid session, Proxy sends empty string (`""`) for `user_id` to LinkService**
74+
- Passes `user_id` via gRPC metadata `x-user-id: <kratos_user_id | "">`
7575

76-
This removes ambiguity: LinkService can distinguish “Kratos error / user missing” from “user is anonymous.
76+
LinkService treats empty `user_id` as anonymous request (no need to distinguish different cases - empty means anonymous).
7777

7878
Proxy never makes privacy decisions.
7979

@@ -84,7 +84,7 @@ Proxy never makes privacy decisions.
8484
1. Fetch the link from DB
8585
2. If `allowed_emails != []`:
8686
- the link is private
87-
- if `user_id == "anonymous"` → immediately return `ErrPermissionDenied` (skip Kratos)
87+
- if `user_id == ""` (empty, anonymous request) → immediately return `ErrPermissionDenied` (skip Kratos)
8888
- otherwise call the **Kratos Admin API** (`GET /admin/identities/{user_id}`) to load the email
8989
- read `identity.traits.email`
9090
- check the allowlist
@@ -101,7 +101,7 @@ Proxy never makes privacy decisions.
101101
- JSON decoding failures
102102
- Missing `traits.email` in the identity
103103
- Email not on the allowlist
104-
- `user_id == "anonymous"`
104+
- `user_id == ""` (empty, anonymous request)
105105

106106
Therefore external observers cannot distinguish between:
107107

@@ -176,7 +176,7 @@ alt KratosPublic returns 200 (valid session)
176176
Proxy -> LS: GetLink(hash)\nmetadata: x-user-id=user_id
177177
else KratosPublic returns 401 (no session)
178178
KratosPublic --> Proxy: 401 Unauthorized
179-
Proxy -> LS: GetLink(hash)\nmetadata: x-user-id=anonymous
179+
Proxy -> LS: GetLink(hash)\nmetadata: x-user-id="" (empty, anonymous)
180180
end
181181
182182
LS -> DB: SELECT * FROM links WHERE hash = ?

0 commit comments

Comments
 (0)