এই পর্বে যা শিখবেন
- “বাজারঘর”-এর চার role-এর জন্য
requireRolemiddleware লেখা - নিজের ল্যাবে IDOR ঘটিয়ে দেখা, তারপর owner দিয়ে query সীমিত করে বন্ধ করা
- নিয়মগুলো এক জায়গায় আনা:
can(user, action, resource)আর CASL - Field-level permission, seller-ভিত্তিক isolation, Prisma extension আর PostgreSQL RLS
- React-এ
<Can>, Vitest-এ role বনাম route ছক, audit log, impersonation আর চলমান session-এ role বদল
১ · পরিচয় এক প্রশ্ন, অনুমতি আরেক প্রশ্ন
পর্ব ৪৫ আর ৪৬-এ login বানানো হয়েছে। এখন server জানে অনুরোধটি কার। কিন্তু জানা আর অনুমতি দেওয়া এক কথা নয়। হোটেলের রিসেপশনে আইডি কার্ড দেখালে তারা বোঝেন আপনি কে। এটি authentication. কিন্তু সেই কার্ডে ৩০২ নম্বর ঘরের দরজা খোলে না। আপনার চাবি শুধু আপনার ঘরের জন্য। এটি authorization.
তত্ত্ব এখানে আবার পড়াব না। DAC, MAC, RBAC, ABAC-এর ভিত্তি আছে Cybersecurity কোর্সের authorization models পর্বে, আর API-র দিক থেকে তিন স্তরের ভাঙন আছে API security পর্বে. এই পর্বের কাজ কোড: Express, Prisma আর React-এ এসব কীভাবে বসে, আর না বসলে কী ঘটে।
কোডে পার্থক্যটি খুব স্পষ্ট। Authentication-এর ফল একটি মান: req.user. Authorization-এর ফল একটি সিদ্ধান্ত: হ্যাঁ বা না। সিদ্ধান্তটি নিতে লাগে তিনটি জিনিস: কে, কী কাজ, কোন জিনিসে।
“বাজারঘর”-এর চার role
| Role | কে | যা পারেন | যা পারেন না |
|---|---|---|---|
CUSTOMER | ক্রেতা মিনা, রাফি | নিজের অর্ডার দেখা | অন্যের অর্ডার, দাম বদল |
SELLER | সুমি (Padma Fish), তানভীর (Sylhet Tea) | নিজের দোকানের পণ্য আর অর্ডার | অন্য দোকানের কিছু |
STAFF | সাপোর্টের নীলা | সব অর্ডার দেখা, দাম ঠিক করা | role বদল, পণ্য মোছা |
ADMIN | আরিফ | সব, audit log-সহ | কোনো কাজ log ছাড়া করা |
একটি অনুরোধকে তিনটি দরজা পার হতে হয়। প্রতিটি দরজার ব্যর্থতার উত্তর আলাদা।
একটি অনুরোধ, তিনটি দরজা
Authentication, role, then ownership
এবার একটি সত্যিকারের অনুরোধ তিন দরজা পার করে দেখি। সুমি Padma Fish-এর seller. তিনি Sylhet Tea-র পণ্যের দাম বদলাতে চাইলেন।
- Request
PATCHসুমির token - Authenticate
req.userআপনি কে - Role
requireRoleপদ মিলল - Ownership
can()পণ্যটি কার - উত্তর
403থামল
PATCH /api/products/3
Authorization: Bearer example-session-sumi
{ "pricePaisa": 1 }
{ id: 3, name: 'sumi', role: 'SELLER', sellerId: 1 }
requireRole(SELLER, STAFF, ADMIN)
role = SELLER → next()
product 3 → sellerId: 2 (Sylhet Tea) user → sellerId: 1 can(user, 'product:update', product) → false
403 {"title":"এই কাজের অনুমতি নেই","status":403}
- অনুরোধ এলো। URL-এ পণ্য
3, header-এ সুমির token, body-তে নতুন দাম। এখনো server কিছুই বিশ্বাস করেনি। - প্রথম দরজা।
authenticatetoken দিয়ে session খুঁজেreq.userবসাল। Token না থাকলে এখানেই401হতো। - দ্বিতীয় দরজা। এই route-এ SELLER চলে, তাই
requireRoleছেড়ে দিল। কোনো customer হলে এখানেই403হতো। - তৃতীয় দরজা। পদ ঠিক, কিন্তু পণ্যটি সুমির নয়: পণ্যের
sellerId2, তাঁর 1. শুধু role দেখলে এই অনুরোধ পাস করে যেত। - উত্তর। সত্যিকারের run-এ
403, দাম বদলায়নি। পণ্যের দাম সবার সামনেই থাকে, তাই এখানে 404 দিয়ে লুকানোর কিছু নেই।
২ · requireRole — দ্বিতীয় দরজার middleware
পর্ব ২৬-এ middleware শিখেছেন। Role যাচাই তারই একটি ছোট উদাহরণ, তবে একটি ফাংশন আরেকটি ফাংশন ফেরত দেয়। ল্যাবটি Express 5.3.0, Prisma 7.10.1 আর আসল PostgreSQL 18.6 (embedded-postgres) দিয়ে চালানো। Prisma-র latest tag এখনো release candidate, তাই stable 7.10.1 বসানো হয়েছে।
// Layer 1: who are you? The role is read from the database on every request.
export async function authenticate(req: Request, res: Response, next: NextFunction) {
const token = req.get('authorization')?.replace(/^Bearer /, '');
const s = token
? await prisma.session.findUnique({ where: { id: token }, include: { user: true } })
: null;
if (!s) return problem(res.set('WWW-Authenticate', 'Bearer'), 401, 'লগইন করা নেই');
const { id, name, role, sellerId } = s.user;
req.user = { id, name, role, sellerId };
next();
}
// Layer 2: may this kind of person do this kind of thing?
export const requireRole = (...allowed: Role[]) =>
(req: Request, res: Response, next: NextFunction) => {
if (!allowed.includes(req.user!.role)) return problem(res, 403, 'এই কাজের অনুমতি নেই');
next();
};app.use('/api', authenticate); // everything under /api needs a login
app.delete('/api/products/:id', requireRole(Role.ADMIN), async (req, res) => {
await prisma.product.deleteMany({ where: { id: idParam.parse(req.params.id) } });
res.status(204).end();
});তিনটি অভ্যাস লক্ষ করুন। প্রথমত, যে route-এ যাচাই ভুলে যাবেন সেটি নীরবে খোলা থাকবে, তাই app.use('/api', authenticate) দিয়ে সবটা আগে বন্ধ, পরে পাবলিক route আলাদা করে খোলা। একে বলে deny by default. দ্বিতীয়ত, role আসে session-এর user থেকে, body থেকে নয়। তৃতীয়ত, প্রতিটি role-কে নাম ধরে লেখা। role >= STAFF ধরনের ক্রমের হিসাব সহজ লাগে, কিন্তু নতুন role যোগ হলে সবকিছু গোলমাল করে।
Role কোথায় রাখবেন
আমাদের ল্যাবে User টেবিলে একটি role enum কলাম, আর seller-এর জন্য sellerId. চারটি স্থির role-এর জন্য এটিই সবচেয়ে সহজ। কারও একসাথে দুটি role দরকার হলে, যেমন একজন seller যিনি আবার staff, তখন আলাদা UserRole টেবিল লাগে। আর একজন মানুষ একাধিক দোকান চালালে role আসলে দোকানের সাথে বাঁধা: “এই দোকানে owner, ওই দোকানে manager”. তখন Membership(userId, sellerId, role) ধরনের টেবিল ভাবুন। শুরুতেই জটিল করবেন না, তবে জেনে রাখুন role কলামটি কোন দিন ছোট হয়ে আসতে পারে। Migration-এর পথ দেখেছেন পর্ব ৪০-এ.
গার্মেন্টস কারখানার গেটে দারোয়ান আইডি দেখেন, এটি authenticate. সেলাই সেকশনের দরজায় সুপারভাইজার দেখেন আপনি এই সেকশনের কর্মী কি না, এটি requireRole. আর লাইনে পৌঁছে দেখা হয় এই লটটি আপনার নামে কাটা কি না। তৃতীয় ধাপটি ছাড়া কারখানায় সবাই সবার লট ধরতে পারত। পরের অংশ ঠিক সেই গল্প।
৩ · Role যেখানে হার মানে — নিজের ল্যাবে IDOR
মিনা আর রাফি দুজনেই CUSTOMER. Role দিয়ে তাঁদের আলাদা করা যায় না। অথচ মিনার অর্ডার রাফির দেখার কথা নয়। URL-এর সংখ্যা বদলে অন্যের জিনিস পাওয়ার এই বাগের নাম IDOR (Insecure Direct Object Reference). API-র জগতে একই জিনিসের নাম BOLA, যা OWASP API Security Top 10-এর ২০২৩ তালিকায় এক নম্বরে। তত্ত্ব আছে broken access control পর্বে. এখানে শুধু নিজের ল্যাবে, নিজের তৈরি ভুয়া ডেটায়, বাগটি ঘটিয়ে বন্ধ করব।
আগে সবচেয়ে ছোট রূপটি। নিচের block-এ একই server দুবার চলে: একবার ভুল find দিয়ে, একবার ঠিকটি দিয়ে।
const http = require('node:http');
// Fake local data. Both users are real logins of this toy shop.
const orders = [
{ id: 1001, userId: 1, address: 'Mirpur 10' },
{ id: 1002, userId: 2, address: 'Zindabazar' },
];
const sessions = { 'example-mina': { id: 1, name: 'mina' } };
const bad = (user, id) => orders.find((o) => o.id === id);
const good = (user, id) => orders.find((o) => o.id === id && o.userId === user.id);
function makeServer(find) {
return http.createServer((req, res) => {
const user = sessions[req.headers.authorization];
if (!user) { res.statusCode = 401; return res.end('{}'); }
const order = find(user, Number(req.url.split('/').pop()));
res.statusCode = order ? 200 : 404;
res.end(JSON.stringify(order ?? {}));
});
}
async function trial(label, find) {
const server = makeServer(find);
await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
const base = `http://127.0.0.1:${server.address().port}`;
for (const id of [1001, 1002, 9999]) {
const r = await fetch(`${base}/orders/${id}`, { headers: { authorization: 'example-mina' } });
console.log(label, 'mina GET', id, '->', r.status, await r.text());
}
await new Promise((ok) => { server.close(ok); server.closeAllConnections(); });
}
trial('BAD ', bad).then(() => trial('GOOD', good));আউটপুটের দ্বিতীয় লাইনটিই পুরো গল্প: BAD mina GET 1002 -> 200 {"id":1002,"userId":2,"address":"Zindabazar"}. Token বৈধ, তাই authentication চুপ। মিনা রাফির ঠিকানা পেয়ে গেলেন। GOOD অংশে সেই একই অনুরোধে GOOD mina GET 1002 -> 404 {}, আর না-থাকা 9999-এও হুবহু একই 404 {}. পার্থক্য এক শর্তে: o.userId === user.id.
এবার আসল stack: Express, Prisma, PostgreSQL. ল্যাবে ভুল route-টি শুধু labBugs চালু থাকলে তৈরি হয়, আর server বসে শুধু 127.0.0.1-এ।
// LAB ONLY: authenticated, but never asks whose order this is.
app.get('/bad/orders/:id', authenticate, async (req, res) => {
const order = await prisma.order.findUnique({ where: { id: idParam.parse(req.params.id) } });
if (!order) return problem(res, 404, 'এই অর্ডারটি পাওয়া যায়নি');
res.json(order);
});
// ownership lives inside the query
app.get('/api/orders/:id', async (req, res) => {
const id = idParam.parse(req.params.id);
const order = await prisma.order.findFirst({ where: { id, ...orderScope(req.user!) } });
if (!order) return problem(res, 404, 'এই অর্ডারটি পাওয়া যায়নি');
res.json(order);
});// The same rule as a WHERE clause, so a list can never hold a row can() would refuse.
export function orderScope(user: AuthUser): Prisma.OrderWhereInput {
if (user.role === Role.STAFF || user.role === Role.ADMIN) return {};
if (user.role === Role.SELLER) return { sellerId: user.sellerId! };
return { userId: user.id };
}সত্যিকারের run, curl-এর উত্তর (Prisma 7.10.1, PostgreSQL 18.6):
# মিনা নিজের অর্ডার, ভুল route
200 {"id":1001,"userId":1,"sellerId":1,"totalPaisa":180000,"address":"Mirpur 10, Dhaka"}
# মিনা শুধু ID বদলালেন, ভুল route
200 {"id":1002,"userId":2,"sellerId":2,"totalPaisa":32000,"address":"Zindabazar, Sylhet"}
# মিনা ID বদলালেন, ঠিক route
404 {"title":"এই অর্ডারটি পাওয়া যায়নি","status":404}
# মিনা এমন ID চাইলেন, যা নেই
404 {"title":"এই অর্ডারটি পাওয়া যায়নি","status":404}
কেন “আনার পরে if” না লিখে query-তে শর্ত? কারণ if ভুলে যাওয়া যায়, আর list route-এ প্রতিটি সারিতে সেই if লাগত। orderScope একবার লিখলে একক অর্ডার আর তালিকা দুটোই একই সীমা পায়। নিচে দুই query-র আসল SQL দেখুন।
id userId address 1001 1 Mirpur 10, Dhaka 1002 2 Zindabazar, Sylhet অনুরোধ: GET /orders/1002 user.id = 1
SELECT ... FROM "public"."Order"
WHERE ("public"."Order"."id" = $1 AND 1=1)
params: [1002, ...]
200 {"id":1002,"userId":2,...,"address":"Zindabazar, Sylhet"}
SELECT ... FROM "public"."Order"
WHERE ("public"."Order"."id" = $1
AND "public"."Order"."userId" = $2)
params: [1002, 1, ...]
good: null
404 {"title":"এই অর্ডারটি পাওয়া যায়নি","status":404}
- দুই সারি, একটি অনুরোধ। মিনার
user.id1. তিনি URL-এ 1002 লিখলেন, যা রাফির (userId2). - ভুল route. SQL-এ মালিকের কোনো শর্ত নেই।
AND 1=1মানে “বাড়তি শর্ত নেই”। Database সৎভাবে যা চাওয়া হয়েছে তাই খুঁজছে। - ফাঁস। সারি মিলল, উত্তর 200. উত্তরের ভেতরে
userId2 দেখেই বোঝা যায় এটি অন্যের। Status দেখে কোনো monitor সন্দেহ করবে না। - ঠিক route. এবার
WHERE-তে দুটি শর্ত, আর দ্বিতীয় মান 1 এসেছে session থেকে, URL থেকে নয়। মিনা URL যা-ই লিখুন, এই মান বদলাতে পারবেন না। - শূন্য সারি। Prisma
nullদিল, উত্তর 404. Database-এর কাছে “অন্যের” আর “নেই” একই ফল, তাই handler-এ আলাদা করার সুযোগই থাকল না।
এটি বইয়ের উদাহরণ নয়। ২০১৮ সালের নভেম্বরে সাংবাদিক Brian Krebs জানান, USPS-এর একটি API-তে লগইন করা যেকোনো ব্যবহারকারী অন্য ব্যবহারকারীদের তথ্য খুঁজতে পারতেন; প্রতিবেদন অনুযায়ী আনুমানিক ছয় কোটি অ্যাকাউন্ট জড়িত ছিল। সমস্যাটি login-এ ছিল না, ছিল “এই তথ্য কি আপনার” প্রশ্নটি না করায়।
৪ · 403 না 404, আর নিয়ম এক জায়গায়: can() ও CASL
পর্ব ৩৩-এ নিয়মটি দেখেছেন: জিনিসটির অস্তিত্ব গোপন হলে 404, গোপন না হলে 403. বাজারঘরে তার রূপ:
| ঘটনা | উত্তর | কেন |
|---|---|---|
| মিনা রাফির অর্ডার চান | 404 | অর্ডারের অস্তিত্ব আর সংখ্যা গোপন তথ্য |
| সুমি অন্য দোকানের পণ্যের দাম বদলান | 403 | পণ্য তো সবাই দেখছেন |
| মিনা audit log চান | 403 | Route-টি গোপন নয়, ক্ষমতা নেই |
Role আর মালিকানার শর্ত route-এ route-এ ছড়িয়ে থাকলে একদিন একটি জায়গা বদলাবেন, আরেকটি ভুলে যাবেন। তাই সব নিয়ম একটি policy function-এ আনুন: can(user, action, resource). Route জিজ্ঞেস করে, policy উত্তর দেয়। অজানা action-এর উত্তর “না”। কত ছোট হতে পারে, দেখুন:
const rules = {
'order:read': (u, r) => ['staff', 'admin'].includes(u.role)
|| r.userId === u.id
|| (u.role === 'seller' && r.sellerId === u.sellerId),
'product:update': (u, r) => ['staff', 'admin'].includes(u.role)
|| (u.role === 'seller' && r.sellerId === u.sellerId),
'product:delete': (u) => u.role === 'admin',
'user:setRole': (u) => u.role === 'admin',
};
function can(user, action, resource = {}) {
const rule = rules[action];
return rule ? rule(user, resource) === true : false; // unknown action: no
}
const people = [
{ id: 1, name: 'mina', role: 'customer' },
{ id: 3, name: 'sumi', role: 'seller', sellerId: 1 },
{ id: 5, name: 'nila', role: 'staff' },
{ id: 6, name: 'arif', role: 'admin' },
];
const checks = [
['order:read', { userId: 1, sellerId: 1 }, 'order of mina'],
['order:read', { userId: 2, sellerId: 2 }, 'order of rafi'],
['product:update', { sellerId: 1 }, 'seller-1 item'],
['product:update', { sellerId: 2 }, 'seller-2 item'],
['product:delete', {}, 'any item'],
['user:setRole', {}, 'any user'],
['report:export', {}, 'not in rules'],
];
console.log('action'.padEnd(16), 'target'.padEnd(14), people.map((p) => p.name.padEnd(5)).join(''));
for (const [action, res, label] of checks) {
const row = people.map((p) => (can(p, action, res) ? 'yes' : '-').padEnd(5)).join('');
console.log(action.padEnd(16), label.padEnd(14), row);
}ছকটি পড়লেই নীতিটি বোঝা যায়। order of mina সারিতে চারজনই yes, কারণ মিনা মালিক আর সুমি সেই দোকানের seller. order of rafi সারিতে শুধু নীলা আর আরিফ। শেষ সারি report:export নিয়মে নেই, তাই আরিফের ঘরেও -: অজানা মানে না। অ্যাডমিনের জন্যও নিয়ম লিখতে হয়, আর এটিই ভালো দিক। লক্ষ করুন, এই rules-এ একটি সূক্ষ্ম ফাঁক আছে, অনুশীলন ৩-এ সেটি ধরবেন।
নিয়ম বেড়ে গেলে হাতে লেখা switch ভারী হয়। তখন লাইব্রেরি কাজে লাগে। CASL একটি জনপ্রিয় JavaScript authorization লাইব্রেরি। লেখার সময়ে @casl/ability 7.1.1 আর @casl/prisma 2.2.0, যার peer dependency-তে Prisma 7 আছে। Prisma 7-এর prisma-client generator-এ @casl/prisma/runtime entry ব্যবহার করতে হয়, package-এর README-তেই তা লেখা।
export function abilityFor(user: U) {
const { can, cannot, build } = new AbilityBuilder(createPrismaAbility);
can('read', 'Product', ['id', 'name', 'pricePaisa', 'sellerId']);
if (user.role === 'CUSTOMER') can('read', 'Order', { userId: user.id });
if (user.role === 'SELLER') {
can('read', 'Order', { sellerId: user.sellerId! });
can('update', 'Product', ['pricePaisa'], { sellerId: user.sellerId! });
}
if (user.role === 'STAFF') {
can('read', 'Order');
can('read', 'Product');
can('update', 'Product', ['pricePaisa']);
}
if (user.role === 'ADMIN') can('manage', 'all');
cannot('delete', 'Order');
return build();
}
const where = accessibleBy(abilityFor(sumi)).ofType('Order');
const rows = await prisma.order.findMany({ where, select: { id: true, sellerId: true } });~/bazarghar$ npx tsx src/casl-run.ts
sumi update own product : true
sumi update seller-2 item: false
mina read own order : true
mina read rafi order : false
mina product fields : [ 'id', 'name', 'pricePaisa', 'sellerId' ]
nila product fields : [ 'id', 'name', 'pricePaisa', 'sellerId', 'costPaisa' ]
sumi WHERE : {"OR":[{"sellerId":1}]}
sumi orders : [{"id":1001,"sellerId":1}]
সত্যিকারের run-এর শেষ দুই লাইন সবচেয়ে কাজের। একই নিয়ম থেকে CASL একটি Prisma where বানিয়ে দিল। অর্থাৎ can() আর orderScope দুবার লিখতে হলো না। হাতে লেখা policy-তে এই দুটি মিলিয়ে রাখা আপনার দায়িত্ব।
একটি ফাঁদ মনে রাখুন। হাতে লেখা পথে can() একটি সারি বিচার করে, আর orderScope অনেক সারি ছেঁকে আনে। দুটি একই নিয়মের দুই রূপ। কেউ একটিতে “staff শুধু নিজের অঞ্চল” যোগ করলেন, অন্যটিতে ভুলে গেলেন, তাহলে একক অর্ডার আটকাবে, কিন্তু তালিকায় সবই আসবে। তাই দুটি ফাংশন এক ফাইলে রাখুন, আর test-এ একক route আর list route দুটোই রাখুন।
লাইব্রেরি কখন? চার role, দশটি নিয়ম হলে হাতে লেখা ফাংশন পড়তে সহজ, নির্ভরতাও কমে। নিয়ম ডেটাবেসে রাখতে হলে, বা একই নিয়ম থেকে query আর UI দুটোই চাইলে CASL-এর মতো লাইব্রেরি সুবিধা দেয়। এ নিয়ে একমত হওয়ার কিছু নেই; দুটোই ঠিক পথ, যদি নিয়ম এক জায়গায় থাকে।
৫ · Field-level permission — সারি নয়, ঘর
এতক্ষণ প্রশ্ন ছিল “এই সারিটি কি আপনার?” এবার প্রশ্ন “এই সারির কোন ঘর আপনি ছুঁতে পারেন?” একে বলে field-level authorization. দুই দিকে ঘটে: লেখা আর পড়া।
লেখা: role ঘরে mass assignment
পর্ব ২৭-এ mass assignment দেখেছেন। সেখানে কথা ছিল, role বদলানোর আলাদা route আসবে এই পর্বে। আগে ক্ষতিটি আসল database-এ দেখি। ভুল route-এ data: req.body, ঠিক route-এ Zod schema আর নাম ধরে লেখা ঘর।
# সত্যিকারের run: রাফি (CUSTOMER) পাঠালেন {"name":"Rafi","role":"ADMIN"}
PATCH /bad/me
200 {"id":2,"name":"Rafi","email":"rafi@bazarghar.example","role":"ADMIN",...}
GET /api/admin/audit # রাফির একই session
200 []
# আরিফ role ফেরালেন, তারপর একই payload ঠিক route-এ
PATCH /api/me
200 {"id":2,"name":"Rafi","role":"CUSTOMER"}
লক্ষ করুন, 200 [] মানে রাফি admin route-এ ঢুকে গেছেন, আর audit log খালি: তাঁর এই উন্নতির কোনো চিহ্ন কোথাও নেই। Role বদলের একটিই পথ থাকা উচিত, PATCH /api/admin/users/:id/role, যা requireRole(Role.ADMIN)-এর পেছনে আর audit-এ লেখে।
পড়া: costPrice সবার জন্য নয়
কেনা দাম (costPrice, আমাদের কলাম costPaisa) জানলে ক্রেতা লাভের হার বুঝে ফেলবেন। UI-তে না দেখালেই চলবে না, কারণ JSON-এ থাকলে browser-এর Network ট্যাবেই দেখা যায়। তাই select role দেখে ঠিক হয়, যাতে database থেকেই না আসে:
// Field-level: which product columns this role may see.
export const productView = (user?: AuthUser) => {
const seesCost = user?.role === Role.ADMIN || user?.role === Role.STAFF;
return { id: true, name: true, pricePaisa: true, sellerId: true, costPaisa: seesCost };
};# সত্যিকারের run: GET /api/products, প্রথম সারি
মিনা 200 [{"id":1,"name":"Ilish 1kg","pricePaisa":180000,"sellerId":1},...]
নীলা 200 [{"id":1,"name":"Ilish 1kg","pricePaisa":180000,"sellerId":1,"costPaisa":150000},...]
এখানে allowlist কাজ করছে: যে ঘর তালিকায় নেই, সে আসে না। কাল কেউ supplierPhone কলাম যোগ করলে সে নিজে থেকেই লুকানো থাকবে। উল্টোটা, মানে পুরো সারি এনে delete row.costPaisa, ভুলে গেলে ফাঁস।
OWASP-এর API তালিকায় এই শ্রেণির নাম BOPLA (Broken Object Property Level Authorization). নামটি বড়, ভাবনা ছোট: লেখার ঘর আর পড়ার ঘর দুটো তালিকা আলাদা রাখুন। সুমি নিজের পণ্যে pricePaisa বদলাতে পারেন, কিন্তু sellerId নয়, নইলে পণ্যটি অন্য দোকানে সরিয়ে দেওয়া যেত। Staff দাম ঠিক করতে পারেন, কিন্তু costPaisa শুধু দেখেন। প্রতিটি role আর প্রতিটি কাজের জন্য আলাদা Zod schema লেখা একটু ক্লান্তিকর, তবে এটিই সবচেয়ে পড়তে সহজ পথ। ল্যাবের productBody-তে তাই শুধু একটি ঘর।
৬ · Multi-tenant seller — প্রতিটি query-তে sellerId
বাজারঘরে অনেক seller একই টেবিল ব্যবহার করেন। এটি multi-tenant নকশা। মডেলগুলোর তুলনা আছে Cybersecurity কোর্সে. কোডে নিয়ম একটি: seller-এর প্রতিটি query-তে sellerId, আর সেই মান আসবে session থেকে। হাতে লিখলে ১০০টি query-র একটিতে ভুল হবেই। তাই দুটি স্তর দেখি।
প্রথম স্তর: Prisma client extension
const SCOPED = new Set(['Product', 'Order']);
const READS = new Set(['findMany', 'findFirst', 'findFirstOrThrow', 'count', 'aggregate', 'groupBy',
'updateMany', 'deleteMany']);
// Every query on Product and Order gets sellerId added, even if the caller forgets it.
export function forSeller(sellerId: number) {
return prisma.$extends({
query: {
$allModels: {
$allOperations({ model, operation, args, query }) {
if (!SCOPED.has(model)) return query(args);
if (READS.has(operation)) {
const a = args as { where?: object };
a.where = { AND: [a.where ?? {}, { sellerId }] };
return query(args);
}
// findUnique, update and delete take a unique where; extra filters are allowed there too.
if (['findUnique', 'findUniqueOrThrow', 'update', 'delete'].includes(operation)) {
const a = args as { where: Record<string, unknown> };
a.where = { ...a.where, sellerId };
return query(args);
}
throw new Error(`forSeller: ${model}.${operation} is not scoped`);
},
},
},
});
}~/bazarghar$ npx tsx src/tenant-run.ts
plain client : 1:Ilish 1kg(s1), 2:Rui 1kg(s1), 3:Black Tea 500g(s2)
forSeller(1) : 1:Ilish 1kg(s1), 2:Rui 1kg(s1)
findUnique id=3 : null
updateMany all : 2
update id=3 : P2025
create : forSeller: Product.create is not scoped
raw count : 3
nested include : 3 products
প্রথম ছয় লাইন আনন্দের: where না লিখলেও সুমি শুধু নিজের ২টি পণ্য পান। অজানা operation-এ ইচ্ছা করেই error, কারণ নিরব ছাড় বিপজ্জনক। কিন্তু শেষ দুই লাইন সতর্কবার্তা। Raw SQL-এ 3 এলো, আর Seller-এর ভেতর include করে পণ্য আনলে তিনটিই, অন্য দোকানেরটিসহ। এই extension শুধু উপরের স্তরের query ধরে; nested read আর raw SQL তার নাগালের বাইরে।
দ্বিতীয় স্তর: PostgreSQL row-level security
RLS-এ নিয়মটি database নিজে প্রয়োগ করে, তাই app-এর কোনো ভুলই সেটি পার হতে পারে না। PostgreSQL 9.5 থেকে এটি আছে।
GRANT SELECT, INSERT, UPDATE, DELETE ON "Product" TO app_user;
ALTER TABLE "Product" ENABLE ROW LEVEL SECURITY;
CREATE POLICY seller_isolation ON "Product"
USING ("sellerId" = NULLIF(current_setting('app.seller_id', true), '')::int)
WITH CHECK ("sellerId" = NULLIF(current_setting('app.seller_id', true), '')::int);function asSeller<T>(sellerId: number, fn: (tx: any) => Promise<T>) {
return prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.seller_id', ${String(sellerId)}, true)`;
return fn(tx);
});
}~/bazarghar$ npx tsx src/rls-run.ts
superuser, no setting : 3 rows
app_user, no setting : 0 rows
app_user, seller 1 : 1:Ilish 1kg, 2:Rui 1kg
update other seller : 0 rows
insert for seller 2 : 42501 new row violates row-level security policy for table "Product"
prisma asSeller(2) : Black Tea 500g
prisma, no transaction: 0 rows
prisma nested include : 1 products
এই সত্যিকারের run তিনটি জিনিস শেখায়, যা docs পড়ে সহজে চোখে পড়ে না।
- Superuser-এ RLS চলে না। প্রথম লাইনে ৩ সারি। PostgreSQL docs অনুযায়ী superuser আর
BYPASSRLSrole সবসময় RLS এড়ায়, টেবিলের owner-ও সাধারণত এড়ায় (FORCE ROW LEVEL SECURITYনা দিলে)। অর্থাৎ app যদি migration-এর user দিয়ে চলে, আপনার policy কাগজেই থাকবে। তাই আলাদাapp_user. - না বললে কিছুই নেই। Setting ছাড়া ০ সারি। ভুলের ফল ফাঁস নয়, ফাঁকা তালিকা। এটিই deny by default. শেষ লাইনে যে nested include extension-এ ফসকে গিয়েছিল, seller 2-এর জন্য সেটি ১টি পণ্য দিল: নিয়ম টেবিলে থাকায় পথ বদলালেও সীমা বদলায় না।
- Pool-এর ফাঁদ। তৃতীয় argument
trueমানে setting শুধু এই transaction-এ। তাইasSellerসব query একটি transaction-এ রাখে। ল্যাবের প্রথম policy-তেNULLIFছিল না। Transaction শেষে একই connection-এ setting ফাঁকা string হয়ে যায়, তাই পরের query ভেঙে পড়ে:invalid input syntax for type integer: "".NULLIFসেই ফাঁকাকে NULL করে, আর শর্ত মেলে না।
RLS-এর খরচও আছে: প্রতিটি অনুরোধে transaction, policy-র শর্তের জন্য index, আর ভুল হলে “সারি নেই” ধরনের নীরব লক্ষণ। Policy-র SQL আর set_config-এর wrapper এই ল্যাবে হাতে লিখতে হয়েছে, তাই সেগুলোর দেখভালও আপনার দায়িত্ব। ছোট দোকানে extension আর test যথেষ্ট হতে পারে। ডেটা ফাঁসের দাম বেশি হলে দুটো স্তরই রাখুন। Database-এর নিরাপত্তার বাকি অংশ পাবেন DBMS কোর্সের database security পর্বে.
| পথ | ভুল হলে | যা ফসকায় (এই ল্যাবে দেখা) |
|---|---|---|
হাতে where | নীরব ফাঁস | যে query-তে লিখতে ভুলেছেন |
| Prisma extension | নীরব ফাঁস বা error | Raw SQL, nested include |
| PostgreSQL RLS | ফাঁকা ফল বা error | Superuser, BYPASSRLS, owner |
একটি নীতি তিন পথেই সমান: sellerId আসবে যাচাই করা session থেকে। URL-এর /sellers/2/products দেখে seller ঠিক করলে আপনি আবার IDOR-এই ফিরে গেলেন, শুধু এক স্তর ওপরে। Cache key, CSV export আর background job-এও sellerId লাগবে; ফাঁস অনেক সময় মূল পাতায় নয়, এসব পাশের পথে হয়।
৭ · Frontend-এর যাচাই শুধু UX-এর জন্য — <Can>
মিনাকে “দাম বদলান” বোতাম দেখানোর মানে নেই। চাপলে 403 আসবে, সেটি খারাপ অভিজ্ঞতা। তাই React-এ বোতাম লুকাই। কিন্তু মনে রাখবেন, browser আপনার নিয়ন্ত্রণে নেই। কেউ curl, DevTools বা নিজের script দিয়ে সরাসরি API-তে যেতে পারেন। লুকানো বোতাম একটি সৌজন্য, তালা নয়। Broken access control পর্বে এর তত্ত্ব পাবেন।
নিচে আসল React চলছে। ব্যবহারকারী বদলে দেখুন বোতাম কীভাবে আসে যায়। “সরাসরি অনুরোধ” বোতামটি লুকানো যায় না, কারণ সেটি যেন কারও নিজের script. Simulated server তখনও নিজেই যাচাই করে।
<div id="root"></div>
<script src="../course-kit/vendor/react-lab.js"></script>
<script type="text/jsx">
const people = [{ name: 'mina', role: 'customer' }, { name: 'sumi', role: 'seller', sellerId: 1 },
{ name: 'arif', role: 'admin' }];
const product = { id: 1, sellerId: 1 };
const can = (u) => u.role === 'admin' || (u.role === 'seller' && u.sellerId === product.sellerId);
// Simulated server: it decides again and never trusts the UI.
const server = (u) => (can(u) ? 200 : 403);
const Can = ({ user, children }) => (can(user) ? children : null);
function Shop() {
const [user, setUser] = useState(people[0]);
const [log, setLog] = useState('');
const send = (from) => setLog(`${user.name}, ${from}: ${server(user)}`);
return (<div>
{people.map((p) => <button key={p.name} onClick={() => setUser(p)}>{p.name}</button>)}
<p>এখন: {user.name}</p>
<Can user={user}><button onClick={() => send('UI')}>দাম বদলান</button></Can>
<button onClick={() => send('script')}>সরাসরি অনুরোধ</button>
<p>{log}</p>
</div>);
}
createRoot(document.getElementById('root')).render(<Shop />);
people.forEach((p) => console.log(p.name, 'button:', can(p) ? 'shown' : 'hidden', '| direct call:', server(p)));
</script>Load হওয়ার সময়ের আউটপুট: mina button: hidden | direct call: 403, sumi button: shown | direct call: 200, arif button: shown | direct call: 200. মিনার বোতাম নেই, তবু সরাসরি অনুরোধ করা গেল, আর server 403 দিল। <Can> মুছে দিলেও কারও বাড়তি ক্ষমতা হবে না, শুধু UI একটু খারাপ হবে। এটিই পরীক্ষা: Frontend-এর সব যাচাই তুলে নিলে যদি নিরাপত্তা না কমে, নকশা ঠিক আছে।
বাস্তবে frontend জানবে কীভাবে? সবচেয়ে সহজ পথ, server-এর উত্তরে নিয়মের ফল পাঠানো, যেমন /api/me-তে role, বা প্রতিটি পণ্যে "canEdit": true. সেটি পর্ব ১৭-এর Context-এ রেখে <Can> পড়বে। CASL-এর @casl/react package-এও Can component আছে (লেখার সময়ে 7.1.0; এই ল্যাবে চালানো হয়নি)। Policy কোড bundle-এ গেলে নিয়মগুলো সবাই পড়তে পারে, তাই গোপন ব্যবসায়িক শর্ত client-এ পাঠাবেন না। লুকানো পাতা আর route guard-ও একই শ্রেণির: পর্ব ২০-এর loader শুধু UX, ডেটা আটকানোর কাজ server-এরই।
৮ · Role বনাম route ছক — যাচাই আছে তার প্রমাণ
Authorization-এর বাগ অদৃশ্য। সব ঠিকঠাক চলে, শুধু কেউ এমন কিছু পান যা পাওয়ার কথা নয়। Happy-path test এটি ধরে না। দরকার একটি test matrix: সারিতে route, কলামে role, প্রতিটি ঘরে প্রত্যাশিত status. ধারণাটি ছোট করে চালান। এই router-এ ইচ্ছা করে একটি ভুল আছে।
// A tiny router with a deliberate mistake: staff can reach the role route.
const inHouse = ['staff', 'admin'];
const routes = {
'GET /orders/1002': (u) => (!u ? 401 : inHouse.includes(u) ? 200 : 404),
'PATCH /products/3': (u) => (!u ? 401 : inHouse.includes(u) ? 200 : 403),
'PATCH /users/2/role': (u) => (!u ? 401 : inHouse.includes(u) ? 200 : 403), // BUG
'GET /audit': (u) => (!u ? 401 : u === 'admin' ? 200 : 403),
};
const who = [null, 'customer', 'seller', 'staff', 'admin'];
const expected = {
'GET /orders/1002': [401, 404, 404, 200, 200],
'PATCH /products/3': [401, 403, 403, 200, 200],
'PATCH /users/2/role': [401, 403, 403, 403, 200],
'GET /audit': [401, 403, 403, 403, 200],
};
let pass = 0, fail = 0;
console.log('route'.padEnd(21), who.map((w) => (w ?? 'anon').padEnd(9)).join(''));
for (const [route, want] of Object.entries(expected)) {
const cells = who.map((w, i) => {
const got = routes[route](w);
if (got === want[i]) { pass++; return String(got).padEnd(9); }
fail++;
return `${got}!=${want[i]}`.padEnd(9);
});
console.log(route.padEnd(21), cells.join(''));
}
console.log(`pass=${pass} fail=${fail}`);শেষ লাইন pass=19 fail=1. ৪টি route গুণ ৫ জন = ২০টি ঘর। ভুল ধরা পড়েছে ঠিক এক ঘরে: PATCH /users/2/role সারির staff কলামে 200!=403. Staff-কে role বদলাতে দেওয়া মানে তিনি নিজেকেই admin করতে পারেন। ছকের সুবিধা হলো, “কোন role-এর কোন route” বোঝার জন্য কোড পড়তে হয় না।
এবার আসল জিনিস: Vitest 5.0.5 আর supertest 7.3.1 দিয়ে আসল Express app আর আসল PostgreSQL-এর ওপর একই ছক। Supertest-এর বিস্তারিত পর্ব ৭৫-এ আসবে।
const matrix: [string, Method, string, object | undefined, Record<Who, number>][] = [
['other order', 'get', '/api/orders/1002', undefined,
{ anon: 401, customer: 404, seller: 404, staff: 200, admin: 200 }],
['other seller product', 'patch', '/api/products/3', { pricePaisa: 33000 },
{ anon: 401, customer: 403, seller: 403, staff: 200, admin: 200 }],
['change role', 'patch', '/api/admin/users/2/role', { role: 'CUSTOMER', reason: 'matrix test' },
{ anon: 401, customer: 403, seller: 403, staff: 403, admin: 200 }],
// ... own order, own product, audit log, delete product
];
describe.each(matrix)('%s', (_name, method, url, body, expected) => {
it.each(Object.keys(expected) as Who[])('%s', async (w) => {
const res = await send(w, method, url, body);
expect(res.status).toBe(expected[w]);
});
});~/bazarghar$ npx vitest run
✓ test/matrix.test.ts > other order > customer 3ms
✓ test/matrix.test.ts > change role > staff 2ms
...
Test Files 1 passed (1)
Tests 38 passed (38)
৭টি route × ৫ জন = ৩৫, আর field-এর ৩টি test মিলে ৩৮. কিন্তু সবুজ test প্রমাণ করে না যে test কাজের। তাই ইচ্ছা করে কোড ভাঙলাম: orderScope-এর শেষ লাইন return { userId: user.id } বদলে return {} করলাম, তারপর সত্যিকারের run:
~/bazarghar$ npx vitest run
❯ test/matrix.test.ts (38 tests | 1 failed)
❯ other order (5)
× customer 7ms
FAIL test/matrix.test.ts > other order > customer
AssertionError: expected 200 to be 404 // Object.is equality
Tests 1 failed | 37 passed (38)
IDOR ফিরে এলো, আর ঠিক other order > customer ঘরটি লাল হলো। “নিজের order দেখা যায়” ধরনের test পাস করেই যেত। নিয়ম: যা পাওয়ার কথা নয়, সেটিও test করুন। পরে নতুন route যোগ হলে ছকে নতুন সারি যোগ করাকে কোড review-র শর্ত বানান।
ছকে কী কী রাখবেন? Anonymous কলামটি কখনো বাদ দেবেন না, কারণ authenticate ভুলে যাওয়া route সেখানেই ধরা পড়ে। “অন্য seller”-এর কলাম রাখুন, শুধু “seller” নয়। Test ডেটায় অন্তত দুজন customer আর দুজন seller থাকুক; একজন করে থাকলে IDOR ধরার উপায়ই থাকে না। Test-এর status-ই শেষ কথা নয়: 200 পেলেও উত্তরের ভেতরে costPaisa আছে কি না দেখুন, যেমন আমাদের fields অংশে। ল্যাবের ছকে test-গুলো একই database ব্যবহার করে আর ক্রমে চলে; test database আর ডেটা আলাদা করার পথ আসবে পর্ব ৭৫-এ।
৯ · দায়, ছদ্মবেশ আর চলমান session
Audit log — কে, কবে, কী, কেন
Admin ভুল করতে পারেন, তাঁর account-ও চুরি হতে পারে। তাই বড় কাজের audit log লাগে। সাধারণ log (পর্ব ৩১) ডিবাগের জন্য; audit log প্রমাণের জন্য। মূল কাজ আর log একই transaction-এ লিখুন, যাতে একটি হলে অন্যটিও হয়।
const updated = await prisma.$transaction(async (tx) => {
const before = await tx.user.findUnique({ where: { id }, select: { role: true } });
if (!before) return null;
const u = await tx.user.update({
where: { id }, data: { role, tokenVersion: { increment: 1 } },
select: { id: true, role: true, tokenVersion: true },
});
await tx.auditLog.create({ data: {
actorId: req.user!.id, action: 'user.role.change', targetType: 'User', targetId: id,
before, after: { role }, reason, requestId: randomUUID(),
} });
return u;
});# সত্যিকারের run: GET /api/admin/audit, একটি সারি (ছোট করা)
{"actorId":6,"onBehalfOf":null,"action":"user.role.change","targetType":"User","targetId":5,
"before":{"role":"ADMIN"},"after":{"role":"STAFF"},
"reason":"cover ended, ticket 4471","createdAt":"2026-11-01T18:34:55.893Z"}
Audit টেবিলে app_user-এর শুধু INSERT আর পড়ার অধিকার রাখুন, UPDATE/DELETE নয়। Password, token বা পূর্ণ কার্ড নম্বর log-এ যাবে না।
Impersonation — গ্রাহক সেজে দেখার ঝুঁকি
সাপোর্টে প্রায়ই শোনা যায়, “আমার পাতায় এটা দেখাচ্ছে না।” নীলা মিনার চোখে পাতাটি দেখতে চান। সুবিধাজনক, কিন্তু এটি নিয়মের ভেতর দিয়ে অন্যের জুতায় পা রাখা। নিচের নিয়মগুলো নকশার পরামর্শ; এই ল্যাবে পুরো feature চালানো হয়নি, শুধু onBehalfOf কলামটি তৈরি।
- দুই পরিচয়ই রাখুন।
actorIdনীলা,onBehalfOfমিনা। পরিচয় পুরো বদলে ফেললে log বলবে “মিনা করেছেন”, যা মিথ্যা। - ডিফল্টে শুধু পড়া। Password বদল, টাকা তোলা বা ঠিকানা বদল ছদ্মবেশে বন্ধ।
- সীমা। কারণ লেখা বাধ্যতামূলক, মেয়াদ ছোট, admin-কে impersonate করা নিষেধ, পাতায় স্পষ্ট ব্যানার।
- গ্রাহক জানবেন। সম্ভব হলে তাঁর account activity-তে দেখান।
চলমান session-এ role বদল
আরিফ কাউকে admin থেকে নামালেন। সেই মানুষটি এখনো লগইন করা। পরের অনুরোধে কী হবে? উত্তর নির্ভর করে role কোথায় পড়া হয়।
Session (পর্ব ৪৫): আমাদের authenticate প্রতিটি অনুরোধে database থেকে role পড়ে, তাই বদল সাথে সাথে পৌঁছায়। সত্যিকারের run-এ একই session দিয়ে নীলা: আগে 403, promote হওয়ার পরে 200, demote হওয়ার পরে আবার 403. JWT (পর্ব ৪৬): token-এর ভেতরে role থাকলে সে পুরোনো কথাই বলে। JWT যাচাইয়ে database লাগে না, এটিই তার গুণ, আবার এটিই এখানে ফাঁদ। নিচে সত্যিকারের run ধরে এগিয়ে দেখুন।
1token = sign({ role, ver })2UPDATE role, ver + 13trustToken(token)4checkVersion(token)5login again, new token
| DB role | |
| DB ver | |
| token role | |
| token ver |
- Token তৈরি। আরিফ লগইন করলেন। Token-এ
role: ADMIN,ver: 0, মেয়াদ ১৫ মিনিট। Database-এও তাই। - Role বদল। Database-এ role হলো STAFF,
tokenVersionহলো 1. কিন্তু browser-এ থাকা token একটি অক্ষরও বদলায়নি, কারণ সেটি তো আগেই সই করা। - শুধু token বিশ্বাস। সই ঠিক আছে, মেয়াদ আছে, তাই
allowed: true. মেয়াদ না ফুরানো পর্যন্ত, এখানে ১৫ মিনিট, তিনি admin-ই থেকে গেলেন। - Version মেলানো। Token-এর
ver0, database-এর 1. মিলল না, তাই401. দাম: প্রতিটি অনুরোধে একটি ছোট read. - আবার লগইন। নতুন token-এ STAFF আর
ver1. এবারstatus: 200, কিন্তুallowed: false: পরিচয় ঠিক, admin কাজের অনুমতি নেই।
| পথ | কত দ্রুত পৌঁছায় | দাম |
|---|---|---|
| প্রতি অনুরোধে DB থেকে role | সাথে সাথে | প্রতি অনুরোধে read |
| JWT, ছোট মেয়াদ, refresh-এ নতুন role | সর্বোচ্চ access token-এর মেয়াদ | ঝুঁকির জানালা থাকে |
tokenVersion মেলানো | সাথে সাথে | প্রতি অনুরোধে ছোট read, চাইলে cache |
| Session মুছে ফেলা | সাথে সাথে, পুরো logout | ব্যবহারকারীকে আবার login |
একটি বাস্তব সিদ্ধান্ত: ক্ষমতা কমানো ঘটনাগুলোতে (কর্মী চলে গেলেন, account সন্দেহজনক) সাথে সাথে কার্যকর পথ নিন। মনে রাখবেন, JWT-র “কোনো database নেই” সুবিধা tokenVersion মেলালে আর থাকে না। অনেক দল এখানেই সিদ্ধান্ত নেয়: আদৌ JWT, নাকি session. আর Redis-এ role cache করলে (পর্ব ৪৪) role বদলের সাথে key মুছতে ভুলবেন না, নইলে JWT-র সমস্যাই নতুন নামে ফিরে আসে।
Role বদল শুধু কোডের ঘটনা নয়, মানুষের ঘটনাও। নীলা আরেক বিভাগে গেলে পুরোনো অধিকার যেন জমে না থাকে; কর্মী চলে গেলে সেদিনই account বন্ধ হয়। এই জীবনচক্রের কথা আছে authorization models পর্বের অংশ ৯-এ. কোডে এর মানে হলো একটি বাড়তি পথ: session সব মুছে দেওয়া, tokenVersion বাড়ানো, আর audit-এ লেখা। “জরুরি ভিত্তিতে এই মানুষটিকে বন্ধ করুন” কাজটি আপনার admin প্যানেলে এক ক্লিকের দূরত্বে থাকা উচিত।
আরও গভীরে: Role-ভিত্তিক মডেলের আনুষ্ঠানিক রূপ David Ferraiolo আর Richard Kuhn-এর ১৯৯২ সালের NIST গবেষণাপত্র থেকে, পরে ANSI/INCITS 359 মান (২০০৪) হয়। “কে কার সাথে সম্পর্কিত” ধরনের নিয়ম বড় হলে শুধু RBAC ভারী হয়; তখন Google-এর ২০১৯ সালের Zanzibar পেপারের মতো relationship-ভিত্তিক নকশা আলোচনায় আসে। PDP আর PEP ভাগ করার তত্ত্ব পাবেন authorization models পর্বের অংশ ৭-এ. আমাদের can() ছোট PDP, আর route ও query তার PEP.
পরীক্ষা ও ইন্টারভিউতে: (১) Authentication আর authorization-এর পার্থক্য? পরিচয় বনাম অনুমতি; 401 বনাম 403. (২) IDOR কীভাবে ঠেকাবেন? Query-তে owner শর্ত, owner আসে session থেকে। (৩) শুধু frontend-এ বোতাম লুকালে হবে? না, API সরাসরি ডাকা যায়। (৪) অন্যের অর্ডারে 403 না 404? অস্তিত্ব গোপন হলে 404. (৫) JWT-তে role বদল কীভাবে পৌঁছাবেন? ছোট মেয়াদ, অথবা tokenVersion মেলানো।
১০ · AI-এর যুগে permission
AI খুব গুছিয়ে CRUD লিখে দেয়। কিন্তু “কে দেখতে পারবেন” কথাটি আপনি না বললে সে ধরে নেয় সবাই। কোড চলে, test পাস করে, শুধু সবার ডেটা সবার সামনে থাকে। Authorization হলো সেই ভুল, যা চালিয়ে দেখলে ধরা পড়ে না।
| AI-এর ভুল | ফল | ধরার উপায় |
|---|---|---|
findUnique({ where: { id } }), মালিকের শর্ত নেই | IDOR | অন্য user দিয়ে test, 404 আশা করে |
update({ data: req.body }) | Mass assignment | role পাঠিয়ে test |
শুধু React-এ if (isAdmin) | API খোলা | Token ছাড়া ও customer দিয়ে route ডাকা |
userId body থেকে নেওয়া | অন্যের নামে কাজ | Diff-এ req.body.userId খোঁজা |
| পুরোনো বা বানানো API, যেমন ভুল CASL import | চলে না, বা নীরবে ভুল | আপনার version-এর docs, README, আসল run |
AI-কে কোড লিখতে বলার সময় নিয়ম আগে দিন, আর উল্টো দিকের test চান:
AI-এর লেখা permission কোড হাতে পেলে
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।
প্র ০১Order ID সংখ্যার বদলে UUID করে দিলেই কি IDOR সমস্যা শেষ?
না। UUID অনুমান করা কঠিন করে, কিন্তু মালিকানা যাচাই করে না। এটি একটি পর্দা, তালা নয়। নিরাপত্তা জগতে একে বলে “security through obscurity”: গোপনীয়তার ওপর ভরসা।
UUID কীভাবে ফাঁস হয়, ভাবুন। অর্ডারের লিংক কেউ সাপোর্টের সাথে শেয়ার করেন। URL থাকে browser-এর history, proxy log, analytics আর Referer header-এ। Seller তাঁর অর্ডারের তালিকায় ক্রেতার ID দেখেন। API-র কোনো response-এ অন্য কারও ID এসে যায়। একবার ID হাতে এলে, মালিকের শর্ত না থাকলে সেটি খুলে যাবে। অর্থাৎ ভুল আগের মতোই রইল, শুধু পরিসর ছোট হলো।
তবু UUID-এর লাভ আছে। ক্রমিক ID দেখে প্রতিযোগী বোঝেন দিনে কত অর্ডার হয়; দুই দিনের দুটি ID বিয়োগ করলেই হলো। ১, ২, ৩ করে গুনে যাওয়া (enumeration) UUID-এ কার্যত বন্ধ হয়, যা 404-এর সাথে মিলে তথ্য ফাঁস কমায়। UUID v4-এর দাম হলো B-tree index-এ এলোমেলো insert; সময়-ক্রমিক UUID v7 (RFC 9562, ২০২৪) সেই সমস্যা কমায়, তবে তাতে তৈরির সময় বোঝা যায়।
একটি বাস্তব ছবি ভাবুন। মিনা তাঁর অর্ডারের লিংকটি ফেসবুক গ্রুপে পোস্ট করলেন, “এই ডেলিভারি কবে আসবে?” লিংকে UUID, কিন্তু এখন সেটি হাজার মানুষের হাতে। মালিকের শর্ত থাকলে অন্যরা 404 পাবেন। শর্ত না থাকলে তাঁরা মিনার ঠিকানা আর ফোন নম্বর পড়বেন। গোপন ID তখন আর গোপন নেই, অথচ কোড বদলায়নি।
অতএব সঠিক ক্রম: আগে query-তে মালিকের শর্ত, তারপর চাইলে অনুমান-কঠিন ID, তারপর rate limit আর সন্দেহজনক ক্রমিক খোঁজের alert. একটি অন্যটির বিকল্প নয়।
মূল উপলব্ধি: অনুমান করা কঠিন বানানো আর অনুমতি যাচাই করা দুটি আলাদা কাজ। প্রথমটি ঝুঁকি কমায়, দ্বিতীয়টি বাগ বন্ধ করে।
প্র ০২২০১২ সালের GitHub-এর mass assignment ঘটনা থেকে framework-গুলো কী শিখল?
২০১২ সালের মার্চে Egor Homakov নামে একজন ডেভেলপার Rails-এ mass assignment-এর বিপদ নিয়ে একটি issue তুলেছিলেন। সাড়া আশানুরূপ না পেয়ে তিনি দেখান যে Rails-এ চলা GitHub-ই এই ভুলে আক্রান্ত: একটি public key-র ঘর বদলে তিনি Rails-এর repository-তে commit করার ক্ষমতা পেয়ে যান। প্রমাণ হিসেবে সত্যিই একটি commit করেন। এরপর GitHub দ্রুত ঠিক করে, আর ঘটনাটি নিয়ে ব্যাপক আলোচনা হয়। তাঁর পদ্ধতি বিতর্কিত ছিল, আজ এমন কাজ অনুমতি ছাড়া করা ঠিক নয়; দায়িত্বশীল রিপোর্টিংয়ের পথ আলাদা।
আসল শিক্ষা নকশায়। সে সময় Rails-এ attr_accessible নামে allowlist ছিল, কিন্তু সেটি ডিফল্টে বাধ্যতামূলক ছিল না। অর্থাৎ নিরাপদ পথটি ছিল ঐচ্ছিক। প্রতিটি মডেলে মনে করে লিখতে হতো, আর মানুষ ভোলে। ঘটনার পর Rails নিরাপদ ডিফল্টের দিকে যায়, পরে strong parameters এনে দায়িত্ব মডেল থেকে controller-এ সরায়।
একই সমস্যা আজ Node-এ অন্য চেহারায়। prisma.user.update({ data: req.body }) ঠিক সেই ভুল। Zod-এর z.object ডিফল্টে অচেনা key বাদ দেয়, যা ভালো ডিফল্ট, কিন্তু handler-কে req.body ছেড়ে schema-র ফল ব্যবহার করতে হবে। আবার কেউ looseObject লিখলে দরজা খুলে যায়। আমাদের ল্যাবে দেখলেন, Rafi নিজেকে admin করলেন আর audit log খালি রইল, কারণ ঘটনাটি audit-করা route দিয়ে যায়নি।
এ কারণে নকশায় দুটি প্রশ্ন করুন। ভুল পথটি কি নিরাপদ পথের চেয়ে সহজ? ভুলে গেলে ফল কী, খোলা না বন্ধ? ভালো API-তে ভুলে গেলে কিছু বন্ধ থাকে।
মূল উপলব্ধি: নিরাপত্তাকে মনে রাখার ওপর ছাড়লে একদিন ভাঙবে। নিরাপদ ডিফল্ট মানুষের ভুলকে আগেই হিসাবে ধরে নেয়।
প্র ০৩Permission যাচাই কোথায় থাকবে: middleware, service, না database? একটি কি যথেষ্ট?
প্রতিটি জায়গা ভিন্ন কিছু জানে, আর এটিই উত্তরের চাবি।
- Middleware জানে কে আর কোন route, কিন্তু জিনিসটি জানে না। তাই role যাচাইয়ের জন্য ভালো, ownership-এর জন্য নয়। সবার আগে চলে, তাই খরচ বাঁচায়।
- Service বা policy (পর্ব ২৯-এর স্তর) ব্যবসার নিয়ম জানে। “বিক্রির পরে দাম বদল চলবে না” এখানে থাকে। HTTP ছাড়া অন্য পথেও, যেমন background job বা GraphQL থেকেও, একই নিয়ম চলে।
- Query সারি জানে।
WHERE-তে শর্ত দিলে যাচাই আর ডেটা আনা এক কাজ, ভুলের জায়গা কম। - Database (RLS) সবার শেষে, কিন্তু কোড যে পথেই আসুক সে ধরে। আমাদের run-এ দেখলেন, Prisma extension-এ raw SQL আর nested include ফসকে গেছে; RLS-এ একই nested include সীমার ভেতরে থেকেছে, কারণ নিয়ম টেবিলে।
তাহলে কি সব জায়গায় সব লিখবেন? না। এতে নিয়ম ছড়াবে, আর দুটি জায়গা অমিল হলে সেটিই বাগ। ভালো নকশা হলো নিয়মের উৎস এক (can() বা CASL), প্রয়োগের জায়গা একাধিক। Cybersecurity কোর্সে একেই বলা হয়েছে সিদ্ধান্ত আর প্রয়োগ আলাদা করা।
একটি সূক্ষ্ম ঝুঁকি হলো “পাশের পথ”। Route-এ যাচাই আছে, কিন্তু CSV export, cache key, background job, search index-এ নেই। ফাঁস প্রায়ই সেখানেই হয়, আর শুধু middleware-নির্ভর নকশা এগুলো দেখে না। Data layer-এ যাচাই থাকলে এসব পথও ঢাকা পড়ে।
Fat controller-এর সমস্যাও এখানে মনে রাখুন। একই permission যদি পাঁচটি route handler-এ ছড়ানো থাকে, নতুন GraphQL বা tRPC পথ যোগ হলে সেগুলো এই নিয়ম পায় না। Service স্তরে থাকলে পায়। তবে শুধু service-এ ভরসা করলে যে কোনো script সরাসরি repository ডেকে পাশ কাটাতে পারে, তাই database-এর সীমা শেষ পাহারা হিসেবে থাকে।
মূল উপলব্ধি: এক স্তরে ভরসা নয়, এক উৎস থেকে কয়েক স্তরে প্রয়োগ। কোন স্তর কী জানে, সেটি দেখে কাজ ভাগ করুন।
প্র ০৪Role সংখ্যা বাড়তে বাড়তে ৫০ হয়ে গেলে কী ভুল হয়েছে, আর পথ কী?
একে বলে “role explosion”. শুরুতে চারটি role. তারপর “seller, তবে শুধু পড়তে পারবেন”, “ঢাকা অঞ্চলের staff”, “refund-করা-যায় এমন staff”. প্রতিটি ব্যতিক্রম একটি নতুন role হয়। কিছুদিন পর কেউ জানেন না কোন role কী করে, মুছতেও কেউ সাহস পান না। ফলে মানুষ বেশি ক্ষমতার role পেয়ে যান, যা least privilege নীতির উল্টো।
গোড়ার কারণ হলো role-এ এমন তথ্য ঢোকানো, যা role-এর নয়। “ঢাকা” একটি বৈশিষ্ট্য, পদ নয়। তাই সমাধানের প্রথম ধাপ role আর permission আলাদা করা: role হলো permission-এর ঝুড়ি (staff = order:read + product:update). কোড সবসময় permission যাচাই করবে, role-এর নাম নয়। এতে requireRole-এর বদলে requirePermission('refund:create') আসে, আর নতুন ঝুড়ি বানাতে কোড বদলাতে হয় না।
দ্বিতীয় ধাপ, বৈশিষ্ট্য দিয়ে শর্ত: “Staff refund করতে পারবেন, যদি পরিমাণ ৫,০০০ টাকার কম হয়”। এটি ABAC-এর ভাবনা, যা আমাদের can()-এ এক লাইনের কাজ। তৃতীয়ত, সম্পর্ক: “দোকানের সদস্যরা দোকানের পণ্য দেখবেন”। বড় পরিসরে Google-এর Zanzibar (২০১৯) ধরনের নকশা সম্পর্ককেই ডেটা বানায়, তবে সাধারণ দোকানে তা অতিরিক্ত।
ভুল সমাধানও আছে। অনেকে admin প্যানেলে সবকিছু কনফিগার করা যায় এমন permission editor বানান। ছোট দলে এটি প্রায়ই অপ্রয়োজনীয়, আর পরীক্ষা করা কঠিন। নিয়ম কোডে থাকলে review হয়, test হয়, version থাকে।
কখন বদলাবেন, তার একটি সহজ লক্ষণ আছে। যদি কোডে if (role === 'STAFF' || role === 'ADMIN' || role === 'AUDITOR') ধরনের লাইন বারবার দেখেন, তাহলে সময় হয়েছে। নতুন role যোগ করতে কতগুলো ফাইল বদলাতে হয়, সেটি গুনে দেখুন; সংখ্যাটি এক হলে নকশা ভালো আছে।
মূল উপলব্ধি: Role হলো সুবিধার নাম, নিয়ম নয়। কোডে যাচাই করুন কাজের অনুমতি; কাকে কোন ঝুড়ি দেবেন, সেটি আলাদা সিদ্ধান্ত।
প্র ০৫Admin আর সাপোর্টের ভেতরের tool-কে কেন আলাদা ঝুঁকি ধরা হয়?
কারণ সেখানে সব নিয়ম ইচ্ছা করেই খোলা। গ্রাহকের পাতায় আমরা মালিকানা মেলাই; সাপোর্ট tool-এর কাজই অন্যের ডেটা দেখা। তাই সেই tool-এর চাবি যাঁর হাতে, তাঁকে ঠকাতে পারলেই পুরো ব্যবস্থা খুলে যায়।
২০২০ সালের জুলাইয়ের Twitter-এর ঘটনা এর পরিচিত উদাহরণ। New York-এর DFS-এর তদন্ত প্রতিবেদন অনুযায়ী, হামলাকারীরা IT help desk সেজে কর্মীদের ফোন করেন, ভুয়া VPN পাতায় নিয়ে লগইন তথ্য নেন, আর শেষে অভ্যন্তরীণ tool-এর নাগাল পেয়ে পরিচিত account-গুলো থেকে একটি প্রতারণামূলক বার্তা ছড়ান। প্রতিবেদন অনুযায়ী প্রায় ১৩০টি account প্রভাবিত হয়। লক্ষ করুন, কোনো software বাগ নয়, মূল দুর্বলতা ছিল মানুষ আর ভেতরের ক্ষমতা।
এ থেকে নকশার কয়েকটি নিয়ম আসে। ন্যূনতম ক্ষমতা: সবাই সবকিছু দেখবেন না, কাজের প্রয়োজনে সাময়িক অধিকার। দুই ধাপের যাচাই, বিশেষত phishing-প্রতিরোধী, যেমন hardware key. বড় কাজে দ্বিতীয়জনের অনুমোদন। প্রতিটি দেখা আর বদলের audit log, আর অস্বাভাবিক ধরনে alert: একজন নীলা এক ঘণ্টায় ৫০০ গ্রাহকের পাতা খুললে কেউ জানুক।
Audit log নিজেও রক্ষা করতে হবে। Log যদি সেই admin-ই মুছতে পারেন, প্রমাণ থাকে না। তাই লেখা-শুধু অধিকার, বা আলাদা জায়গায় পাঠানো। Impersonation-এ দুই পরিচয় রাখার কারণও এটি।
ছোট দলের জন্য অতিরিক্ত? সব নয়। আলাদা admin route, ছোট মেয়াদের session, 2FA আর audit টেবিল, এই চারটি প্রথম দিন থেকেই সস্তা।
মূল উপলব্ধি: গ্রাহকের দরজায় তালা লাগানোই যথেষ্ট নয়। সবচেয়ে বেশি ক্ষমতা যেখানে, পাহারা আর প্রমাণ সেখানেই সবচেয়ে বেশি লাগে।
অনুশীলন
হাতে হিসাব, নকশা আর আউটপুট আগাম বলা। সব পরীক্ষা নিজের ল্যাবেই।
-
ছকের হিসাব: বাজারঘরে ১২টি route আর ৫ রকম ব্যবহারকারী (anonymous, customer, seller, staff, admin). প্রতিটি ঘরের জন্য একটি test. (ক) মোট কতটি test? (খ) ধরে নিন প্রতিটি test গড়ে ৩ ms নেয়; মোট কত সময়? (গ) নতুন role
AUDITORযোগ করলে কতটি test বাড়বে?(ক) ১২ × ৫ = ৬০টি. (খ) ৬০ × ৩ ms = ১৮০ ms, অর্থাৎ ০.১৮ সেকেন্ড. এটি ধরে নেওয়া মান; আমাদের ল্যাবের এক run-এ বেশিরভাগ test ১ থেকে ১০ ms নিয়েছে, যা শুধু একটি স্থানীয় মাপ। (গ) ১২ × ৬ = ৭২, তাই ১২টি বাড়বে। ছকের দাম নতুন কলামে মাত্র এক লাইন, কিন্তু প্রতিটি route-এ সিদ্ধান্ত নিতে হবে।
-
নকশা ও সমালোচনা: AI নিচের route দিল। এতে কী কী সমস্যা?
app.patch('/api/orders/:id', async (req, res) => { const o = await prisma.order.update({ where: { id: +req.params.id }, data: req.body }); res.json(o); });- মালিকের শর্ত নেই: যেকোনো লগইন করা user যেকোনো অর্ডার বদলাতে পারেন।
where-তেorderScope(req.user)লাগবে। - Mass assignment:
data: req.bodyদিয়েtotalPaisaবাuserIdবদলানো যায়। Zod schema আর নাম ধরে লেখা ঘর লাগবে। - ID যাচাই নেই:
+req.params.idভুল ইনপুটেNaN. - Role নেই: Customer কি অর্ডার বদলাতে পারবেন? সিদ্ধান্ত লেখা নেই।
- না পেলে কী: Prisma P2025 দেবে, যা 404-এ রূপান্তর করতে হবে।
- Response-এ সব ঘর, audit নেই:
selectআর audit log যোগ করুন।
- মালিকের শর্ত নেই: যেকোনো লগইন করা user যেকোনো অর্ডার বদলাতে পারেন।
-
আউটপুট আগাম বলুন: নিচের কোড চালালে তিনটি লাইনে কী ছাপা হবে? তারপর Node-এ চালিয়ে মিলিয়ে দেখুন।
const can = (u, r = {}) => u.role === 'seller' && r.sellerId === u.sellerId;এরপরconsole.log(can({ role: 'seller', sellerId: 1 }, { sellerId: 2 })),console.log(can({ role: 'seller' }, { sellerId: 2 })), আরconsole.log(can({ role: 'seller' })).আউটপুট:
false,false,true. শেষ লাইনটিই ফাঁদ। User-এরsellerIdনেই, resource-এরও নেই, আরundefined === undefinedসত্য!policy.js-এরrules-এর ফাঁকটি এটিই, আসলrulesধরে চালিয়েও একই ফল পেয়েছি। কেউ resource না দিয়েcanডাকলে অনুমতি পেয়ে যাবেন। ঠিক করতে দুই পাশের মান আছে কি না আগে দেখুন:u.sellerId != null && r.sellerId === u.sellerId. একই ফাঁকr.userId === u.id-তেও। ছকে “resource ছাড়া” কলামটিও রাখুন। -
কমান্ড ও পরীক্ষা: নিজের কোনো Express প্রজেক্টে দুজন ব্যবহারকারী বানিয়ে একটি route-এ owner শর্ত মুছে দিন। Test লাল হয় কি? না হলে কোন test লিখবেন? পরীক্ষা শেষে শর্তটি ফিরিয়ে আনুন। কাজটি শুধু নিজের local প্রজেক্টে করুন।
আমাদের ল্যাবে
orderScopeভাঙলেnpx vitest run-এTests 1 failed | 37 passed (38)এসেছে। আপনার test লাল না হলে সম্ভবত আপনি শুধু “নিজের ডেটা পাওয়া যায়” পরীক্ষা করছেন। যোগ করুন: user A লগইন করে user B-র ID চাইবেন, প্রত্যাশা 404. একে mutation-এর হাতে-করা রূপ ভাবতে পারেন: ইচ্ছা করে ভুল ঢুকিয়ে দেখা test সেটি ধরে কি না।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমএকটি অনুরোধে server-এর যাচাই সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- 🪪 Token বা session থেকে ব্যবহারকারী চেনা
- 🎖️ এই route-এ তাঁর role চলে কি না দেখা
- 🛂 URL-এর id Zod দিয়ে যাচাই করা
- 🔑 Owner শর্তসহ query চালানো
- ✂️ Role অনুযায়ী ঘর বেছে উত্তর পাঠানো
লগইন করা customer URL-এর order id বদলে অন্য customer-এর অর্ডার পেয়ে গেলেন। Status 200. এই বাগের নাম কী?
- CSRF
- IDOR বা BOLA
- XSS
- Mass assignment
IDOR ঠেকাতে Prisma-তে সবচেয়ে ভালো অভ্যাস কোনটি?
- Order id-কে UUID করা
- Body-তে userId পাঠিয়ে
where-তে সেটি বসানো where: { id, userId: req.user.id }দিয়ে query করা- React-এ userId মিলিয়ে তবেই পাতা দেখানো
একজন seller অন্য seller-এর পণ্যের দাম বদলাতে চাইলেন। পণ্যের তালিকা সবার জন্য খোলা। কোন status সবচেয়ে মানানসই?
- 403
- 401
- 422
- 500
React-এ customer-এর জন্য “পণ্য মুছুন” বোতাম লুকানো আছে। DELETE route-এ কোনো যাচাই নেই। কী ঘটবে?
- React অনুরোধটি আটকে দেবে
- Browser DELETE পাঠাতে দেবে না
- CORS আটকে দেবে
- সরাসরি অনুরোধ করলে পণ্য মুছে যাবে
PostgreSQL টেবিলে row-level security চালু করে policy বানানো হলো, তবু app সব সারি দেখছে। App যে user দিয়ে চলছে, সে superuser. কারণ কী?
- Database restart করা হয়নি
- Superuser RLS এড়িয়ে যায়
- Prisma RLS বন্ধ করে দেয়
- RLS-এর জন্য আলাদা extension বসাতে হয়
JWT-তে role রাখা, মেয়াদ ১৫ মিনিট, আর server শুধু সই যাচাই করে। t = ০ মিনিটে token পাওয়া admin-কে t = ২ মিনিটে database-এ STAFF করা হলো। t = ১০ মিনিটে তিনি admin route ডাকলে কী হবে?
- 403, কারণ database-এ role বদলেছে
- 401, কারণ token-এর মেয়াদ শেষ
- 200, কারণ token এখনো ADMIN বলছে
- 500, কারণ role দুই জায়গায় আলাদা
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- তিন প্রশ্ন: কে, কোন পদ, কার জিনিস। উত্তর না মিললে 401, 403, আর গোপন জিনিসে 404.
- Role একা IDOR ঠেকায় না। Owner শর্ত query-র ভেতরে, মান আসে session থেকে।
- নিয়ম থাকবে এক জায়গায় (
can()বা CASL), অজানা মানে না; ঘর বাছাই হবে allowlist দিয়ে। - Seller-এর
sellerIdস্তরে স্তরে: extension nested আর raw ফসকায়, RLS superuser-এ চলে না। - Frontend UX, server authority; test matrix, audit log আর role বদলের পথ প্রমাণ করে নকশাটি কাজ করছে।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৪৯ · Account-এর কাজ — signup, email যাচাই, password reset আর rate limitপরবর্তী পর্বSignup, email যাচাই, password reset আর login-এর rate limit বাস্তবে।
- ফুল-স্ট্যাক ওয়েব ফ্রেমওয়ার্ক · কোর্স হোম৯৩টি পর্বপরের পর্ব ৪৯: Account-এর কাজ — signup, email যাচাই, password reset আর rate limit.
- Authorization modelsতত্ত্বDAC, MAC, RBAC, ABAC, ReBAC আর PDP ও PEP.
- API securityগভীরে যানAuthorization-এর তিন স্তর আর OWASP API তালিকা।
- Broken access controlপাশের ট্র্যাকIDOR, mass assignment আর deny by default-এর নিরাপত্তার দিক।
- Sessions আর tokensপাশের ট্র্যাকToken বাতিল করার তত্ত্ব।
- Database securityপাশের ট্র্যাকDatabase-এর user, অধিকার আর নিরাপত্তা।