Multi-Tenant: Mỗi Người Một Vùng Dữ Liệu, và Chặn IDOR
Sau bài trước, mỗi link gắn với một chủ thật qua claim sub. Nhưng gắn chủ chưa đủ; hệ thống vẫn chưa phân tách dữ liệu theo chủ. Người dùng A phải chỉ thấy link của A, và quan trọng hơn, A không được sửa hay xóa link của B kể cả khi A đoán đúng mã. Bài này dựng đúng hai ràng buộc đó, và kiểm chúng bằng hai người dùng thật.
Mục tiêu
Thêm GET /links liệt kê link giới hạn theo danh tính trong token, và DELETE /links/{code} kiểm quyền sở hữu ngay trong thao tác xóa để chặn lỗ hổng IDOR. Test với hai user A và B để xác nhận mỗi người chỉ thấy dữ liệu của mình và không đụng được dữ liệu người kia. Vẫn dùng stack hiện có, chi phí không đáng kể.
Multi-tenant nghĩa là gì ở đây
Một hệ thống multi-tenant phục vụ nhiều người dùng trên cùng hạ tầng, và mỗi người chỉ được thấy và thao tác trên phần dữ liệu của mình. Với URL shortener, "tenant" là từng người dùng Cognito. Có hai mặt phải đảm bảo: đọc bị giới hạn (liệt kê chỉ ra link của chính mình), và ghi bị giới hạn (sửa/xóa chỉ tác động lên link của chính mình). Cả hai phải dựa trên danh tính do hệ thống xác thực, không phải trên giá trị do client gửi lên.
Điểm thứ hai cần làm đúng: một sai lầm thường gặp là nhận ownerId từ body hay query string rồi tin nó. Khi đó bất kỳ ai cũng có thể đặt ownerId thành người khác. Cách đúng là chỉ tin claim sub trong token đã được API Gateway xác thực, thứ client không giả được.
Liệt kê theo danh tính token
GSI1 dựng ở bài 05 cho phép query link theo USER#<ownerId>. Handler liệt kê lấy owner từ claim, rồi query đúng phân vùng đó trên index:
const owner = event.requestContext.authorizer?.jwt?.claims?.sub as string;
if (!owner) return json(401, { error: "thieu danh tinh nguoi dung" });
const res = await ddb.send(
new QueryCommand({
TableName: TABLE,
IndexName: "GSI1",
KeyConditionExpression: "GSI1PK = :u",
ExpressionAttributeValues: { ":u": `USER#${owner}` },
ScanIndexForward: false,
})
);
Sự cách ly nằm ở chỗ GSI1PK luôn dựng từ owner lấy trong token, không nhận từ đâu khác. Người dùng không có cách nào yêu cầu liệt kê link của người khác, vì giá trị query bị buộc vào danh tính đã xác thực. Route này gắn Authorizer: CognitoAuthorizer nên claim luôn có mặt.
GET /links + Bearer <token A>
│
▼
API Gateway xác thực token ─▶ claims.sub = A
│
▼
Lambda query GSI1 voi GSI1PK = USER#A (buoc theo danh tinh, client khong doi duoc)
│
▼
chi tra ve link cua A
Một lưu ý về định tuyến: ta có cả GET /links (liệt kê) lẫn GET /{code} (mở link). Chúng không đụng nhau vì API Gateway ưu tiên route khớp chính xác hơn route biến, nên /links đi tới handler liệt kê còn /aK9xQ2z đi tới handler mở link.
IDOR và cách chặn trong tầng dữ liệu
IDOR (Insecure Direct Object Reference) là lỗ hổng khi một người truy cập được tài nguyên của người khác chỉ bằng cách đổi mã định danh trong request. Với URL shortener, đó là việc B gọi DELETE /links/<mã của A> và xóa được link của A. Việc đăng nhập không tự nó chặn điều này; B vẫn là người dùng hợp lệ, chỉ là đang đụng vào tài nguyên không phải của mình.
Cách chặn chắc nhất là kiểm quyền sở hữu ngay trong thao tác ghi, bằng ConditionExpression, để không có khe hở giữa lúc kiểm và lúc xóa:
await ddb.send(
new DeleteCommand({
TableName: TABLE,
Key: linkKey(code),
ConditionExpression: "attribute_exists(PK) AND ownerId = :me",
ExpressionAttributeValues: { ":me": owner },
})
);
Điều kiện chỉ cho xóa khi item tồn tại và ownerId của nó bằng người đang gọi. Nếu link không tồn tại, hoặc tồn tại nhưng thuộc về người khác, điều kiện fail và DynamoDB ném ConditionalCheckFailedException. Handler bắt lỗi đó và trả 404 cho cả hai trường hợp:
if ((err as { name?: string }).name === "ConditionalCheckFailedException") {
return json(404, { error: "link khong ton tai hoac khong thuoc ve ban" });
}
Trả 404 thay vì 403 là có chủ đích. Nếu trả 403 ("cấm") cho link của người khác và 404 ("không có") cho link không tồn tại, kẻ tấn công phân biệt được mã nào đang tồn tại trong hệ thống. Trả 404 cho cả hai khiến hai trường hợp không phân biệt được từ bên ngoài, không rò rỉ sự tồn tại của tài nguyên.
Thử với hai người dùng
Tạo hai user A (alice) và B (bob), lấy token mỗi người, rồi mỗi người tạo một link. A liệt kê link, chỉ thấy của mình:
$ curl -s "$API/links" -H "Authorization: Bearer $TOKEN_A"
{"links":[{"code":"465KnY7","target":"https://alice.example/page","clicks":0,"createdAt":"2026-05-25T16:51:06.895Z"}]}
B liệt kê, cũng chỉ thấy của mình, không thấy link của A:
$ curl -s "$API/links" -H "Authorization: Bearer $TOKEN_B"
{"links":[{"code":"t08fg30","target":"https://bob.example/page","clicks":0,"createdAt":"2026-05-25T16:51:07.744Z"}]}
Phép thử chính nằm ở đây: B biết mã link của A (465KnY7) và thử xóa nó:
$ curl -s -X DELETE "$API/links/465KnY7" -H "Authorization: Bearer $TOKEN_B"
{"error":"link khong ton tai hoac khong thuoc ve ban"}
B nhận 404, và link của A vẫn còn nguyên trong bảng:
$ aws dynamodb get-item --table-name url-shortener \
--key '{"PK":{"S":"LINK#465KnY7"},"SK":{"S":"META"}}' --query 'Item.ownerId.S'
"f9cad55c-..."
IDOR bị chặn ngay tại tầng dữ liệu. Còn A xóa chính link của mình thì thành công:
$ curl -s -X DELETE "$API/links/465KnY7" -H "Authorization: Bearer $TOKEN_A"
{"deleted":"465KnY7"}
Cùng một request DELETE trên cùng một mã, kết quả khác nhau hoàn toàn tùy người gọi. Khác biệt không nằm ở code đường đi mà nằm ở điều kiện ownerId = :me so danh tính token với chủ thật của item.
🧹 Dọn dẹp
aws dynamodb delete-item --table-name url-shortener \
--key "{\"PK\":{\"S\":\"LINK#$CODE_B\"},\"SK\":{\"S\":\"META\"}}"
aws cognito-idp admin-delete-user --user-pool-id "$POOL" --username alice@example.com
aws cognito-idp admin-delete-user --user-pool-id "$POOL" --username bob@example.com
Giữ lại stack, user pool và bảng cho phần sau.
Tổng kết
URL shortener giờ là multi-tenant đúng nghĩa. Liệt kê link bị buộc theo danh tính trong token nên mỗi người chỉ thấy dữ liệu của mình, và xóa link kiểm quyền sở hữu ngay trong thao tác bằng ConditionExpression nên một người không đụng được link của người khác dù đoán đúng mã. Trả 404 cho cả "không có" lẫn "không phải của bạn" để không rò rỉ sự tồn tại. Cốt lõi của cả hai là chỉ tin danh tính do hệ thống xác thực, không tin giá trị từ client.
Phần lõi và phần người dùng tới đây đã xong. Bài sau mở Phần IV, phần event-driven, nơi sản phẩm bắt đầu thú vị: mỗi lượt mở link sẽ phát một sự kiện lên EventBridge, tách việc ghi nhận click ra khỏi đường chuyển hướng. Ta sẽ dựng một custom event bus, định nghĩa luật lọc sự kiện, và đặt nền cho analytics realtime ở các bài kế.