সরাসরি পর্বে যান
পর্ব ৫১ · ৬৭-এর মধ্যে · মডিউল ৮

ডেটাবেস নিরাপত্তা — SQL injection প্রতিরোধ, privilege আর encryption

Database Security — Preventing SQL Injection, Least Privilege, Roles, Row-Level Security, Encryption and Auditing

Taught in Bangla, this lesson covers database security: the CIA triad and threat models, how SQL injection works and how parameterised queries, allow-lists and safe dynamic SQL prevent it, authentication, roles, GRANT, REVOKE and least privilege, row-level security, encryption and password hashing, auditing, backups and defence in depth, and privacy, law and masked test data.

এই পর্বে যা শিখবেন

  • CIA triad আর threat model — কে, কোন পথে ডেটার ক্ষতি করতে পারে
  • SQL injection কেন ঘটে, একটি ছোট নিজস্ব উদাহরণে, আর parameterised query কেন একে মূল থেকে বন্ধ করে
  • Allow-list, নিরাপদ dynamic SQL, ORM-এর সঠিক ব্যবহার, নিরাপদ ত্রুটি-বার্তা আর second-order injection
  • Authentication (SCRAM, TLS) আর authorization (role, GRANT/REVOKE, view, row-level security)
  • Encryption, password hashing, audit, backup, privacy-র নীতি আর test-এর জন্য masked ডেটা — লাইভ SQL-এ

১ · নিরাপত্তা আসলে কী রক্ষা করে — CIA triad আর threat model

পর্ব ৪৯-এ দেখেছেন, অ্যাপ কীভাবে driver দিয়ে ডেটাবেসে কোয়েরি পাঠায়; পর্ব ৫০-এ দেখেছেন ORM. এবার প্রশ্ন: এই পথে কী কী ভুল হতে পারে, আর ভুল হলে ক্ষতি কতটা সীমিত রাখা যায়? ডেটাবেস নিরাপত্তার আলোচনা সাধারণত তিনটি শব্দ দিয়ে শুরু হয়, যাদের একসাথে বলে CIA triad:

🔒 Confidentiality — গোপনীয়তা

যার পড়ার অধিকার নেই, সে যেন না পড়ে। গ্রাহকের ফোন নম্বর, NID, বেতন, রোগীর রিপোর্ট। ভাঙে ডেটা ফাঁস হলে।

রক্ষক: GRANT, view, encryption

✅ Integrity — অখণ্ডতা

যার বদলানোর অধিকার নেই, সে যেন না বদলায়; বদল হলে যেন ধরা যায়। কেউ নিজের পরীক্ষার গ্রেড বা bKash-এর মতো লেনদেনের অঙ্ক বদলালে ভাঙে।

রক্ষক: privilege, constraint, audit

⏱️ Availability — প্রাপ্যতা

যার দরকার, সে যেন দরকারের সময় পায়। টেবিল মুছে গেলে, ransomware ফাইল আটকে দিলে, বা ভারী কোয়েরিতে সার্ভার বসে গেলে ভাঙে।

রক্ষক: backup, replication, সীমা

তিনটি একে অপরকে টানে। সব কলাম encrypt করলে গোপনীয়তা বাড়ে, কিন্তু key হারালে ডেটা চিরতরে অপ্রাপ্য। তাই নিরাপত্তা মানে “সব বন্ধ করা” নয়; মানে ঝুঁকি বুঝে প্রতিটি স্তরে সঠিক পরিমাণ পাহারা।

ঝুঁকি বুঝতে প্রথম কাজ একটি threat model: কে ক্ষতি করতে পারে, কোন পথে, আর কী হারাতে পারি। ডেটাবেসের জন্য তিন রকম উৎস প্রায় সবসময় থাকে:

  • বাইরের লোক: ইন্টারনেটে খোলা ডেটাবেস পোর্ট খোঁজা স্বয়ংক্রিয় স্ক্যানার, চুরি যাওয়া password দিয়ে লগইনের চেষ্টা, ওয়েবসাইটের ফর্মে উল্টাপাল্টা ইনপুট।
  • ভেতরের লোক: কৌতূহলী কর্মী যিনি পরিচিত কারও তথ্য দেখেন, বিদায়ী কর্মী যাঁর অ্যাকাউন্ট বন্ধ হয়নি, আর সবচেয়ে বেশি — সৎ মানুষের ভুল: production-এ ভুল টেবিলে DELETE, ভুল জায়গায় পাঠানো backup.
  • আক্রান্ত অ্যাপ বা যন্ত্র: ওয়েব অ্যাপে একটি বাগ, পুরোনো একটি library, কোনো ডেভেলপারের ল্যাপটপে রাখা connection string, বা GitHub-এ ভুল করে তোলা password. এখানে আক্রমণকারী অ্যাপের পরিচয়েই ডেটাবেসে কথা বলে।

একটি ব্যাংকের শাখা ভাবুন। বাইরের দরজায় গার্ড (network আর লগইন), ভেতরে প্রতিটি কর্মীর কাজ অনুযায়ী চাবি — ক্যাশিয়ার ভল্টে ঢোকেন না (privilege), ভল্টের ভেতরে প্রতিটি লকার আলাদা তালায় (row-level security), CCTV আর রেজিস্টারে কে কখন কী করল (audit), আর মূল কাগজের একটি কপি অন্য শহরে (backup). কোনো একটি ব্যবস্থাই নিখুঁত নয়; নিরাপত্তা আসে স্তরগুলো একসাথে থাকায়।

এই পর্বের উদাহরণগুলো সব নিজেদের বানানো ছোট টেবিলে। উদ্দেশ্য একটাই: আপনার নিজের অ্যাপ আর ডেটাবেস কীভাবে নিরাপদ রাখবেন, তা বোঝা।

২ · SQL injection — ডেটা আর কোড যখন একই লেখায় মিশে যায়

অ্যাপ ডেটাবেসকে যা পাঠায়, তা একটি লেখা — SQL. সমস্যা শুরু হয় যখন ডেভেলপার এই লেখা বানান স্ট্রিং জোড়া দিয়ে: SQL-এর একটি টুকরো, তার সাথে ফর্মে ব্যবহারকারী যা লিখেছেন, তারপর আবার SQL-এর টুকরো। তখন ডেটা (ব্যবহারকারীর লেখা) আর কোড (প্রোগ্রামারের লেখা SQL) একই লেখায় মিশে যায়, আর DBMS-এর কাছে আলাদা করে চেনার কোনো উপায় থাকে না কোনটি কার।

SQL-এ লেখা মান থাকে একক উদ্ধৃতিচিহ্নের ভেতরে — string literal, যেমন 'Dhaka'. ইনপুটে নিজেই যদি একটি ' থাকে, জোড়া লেখায় সেটি literal-কে আগেভাগে বন্ধ করে দেয়, আর ইনপুটের বাকি অংশ আর মান থাকে না — DBMS সেটিকে SQL হিসেবে পড়ে। একেই বলে SQL injection: বাইরের লেখা কোয়েরির গঠনে ঢুকে পড়া। কেউ ইচ্ছা করে লেখা সাজিয়ে পাঠালে কোয়েরির শর্তই বদলে যেতে পারে — যে সারি দেখার কথা নয় তা দেখা, যে সারি বদলানোর কথা নয় তা বদলানো বা মোছা। ক্ষতি কতটা হবে, তা নির্ভর করে অ্যাপের ডেটাবেস অ্যাকাউন্টের অধিকারের ওপর (অংশ ৫)।

পদ্ধতিটি বুঝতে আক্রমণের দরকার নেই — একটি সৎ নামই যথেষ্ট। সিলেটের একজন গ্রাহকের নাম Shamim O'Brien; নামের ভেতরেই একটি '. নিচের ডায়াগ্রামে একই খোঁজ দুই পদ্ধতিতে দেখুন:

ডেটাবেস যা পায়লাল = এই ধাপের মূল কথা

            
অবস্থা
পদ্ধতি
কোয়েরির গঠন ঠিক করে
ফল
  1. অ্যাপের কোড। কোয়েরি বানানো হচ্ছে SQL-এর টুকরোর সাথে সার্চ-বাক্সের লেখা জুড়ে। ডেভেলপার ধরে নিয়েছেন, নামে শুধু অক্ষর আর ফাঁকা জায়গা থাকবে।
  2. সাধারণ নাম। “Rahim Uddin” — জোড়া লেখাটি ঠিক যেমন চাওয়া হয়েছিল। ১টি সারি পাওয়া গেল।
  3. নামের ভেতরে '. “Shamim O'Brien” জোড়া লেখায় বসতেই নামের ' চিহ্নটি অ্যাপের খোলা literal বন্ধ করে দিল। এখন লেখার কোন অংশ মান আর কোন অংশ SQL, তা ঠিক করছে ইনপুট — প্রোগ্রামার নন।
  4. DBMS-এর পড়া। 'Shamim O' একটি স্ট্রিং, তারপর Brien একটি অচেনা SQL শব্দ — syntax error. এখানে ক্ষতি শুধু একটি ব্যর্থ খোঁজ। কিন্তু এই একই ফাঁক দিয়ে কেউ ইচ্ছা করে বৈধ SQL-এর আকারে লেখা পাঠালে ত্রুটি হয় না — কোয়েরি চুপচাপ অন্য কাজ করে।
  5. Parameterised. SQL-এর লেখায় ইনপুটের জায়গায় শুধু ?, একটি placeholder. DBMS লেখাটি আগে parse করে গঠন ঠিক করে ফেলে: “name একটি মানের সমান”। এই গঠন পরে আর বদলানো যায় না।
  6. মান আলাদা। নামটি আসে আলাদা পথে, পুরোটা একটি মান হিসেবে — ১৪ অক্ষর, যার একটি '. ১টি সারি পাওয়া গেল। মান কখনো SQL হিসেবে পড়া হয় না, তাই ইনপুটে যা-ই থাকুক, কোয়েরির গঠন বদলায় না।

এই পর্বের লাইভ SQL চলে দোকানের ডেটাবেসে (db-shop): customers (customer_id, name, city, phone, joined_on — ৮ জন; phone-এ UNIQUE, Tanvir-এর phone NULL), categories, products (product_id, name, category_id, price, stock), orders (order_id, customer_id, order_date, status — ১০টি অর্ডার) আর order_items (order_id, product_id, quantity, unit_price). প্রতিবার চালালে ডেটাবেস নতুন করে তৈরি হয়; প্রথম ত্রুটিতে রানার থেমে যায়। নমুনা ডেটা দেখতে এডিটরের নিচের বাক্সটি খুলুন।

হাতে SQL লিখলে নিয়মটি সহজ: literal-এর ভেতরে ' লিখতে হয় পরপর দুবার — ''. এটি SQL standard-এর escape নিয়ম, সব বড় DBMS-এ চলে। প্রথম INSERT নিয়ম মেনে, শেষটি নিয়ম ভেঙে:

INSERT INTO customers (customer_id, name, city, phone, joined_on)
VALUES (9, 'Shamim O''Brien', 'Sylhet', NULL, '2025-10-20');
SELECT customer_id, name, length(name) AS len
FROM customers WHERE name LIKE '%''%';
-- উদ্ধৃতিচিহ্ন দুবার না লিখলে স্ট্রিং মাঝপথে শেষ হয়
INSERT INTO customers (customer_id, name, city, phone, joined_on)
VALUES (10, 'Shamim O'Brien', 'Sylhet', NULL, '2025-10-20');

প্রথম INSERT-এ “১টি সারি বদলেছে”, আর SELECT দেখায় 9 | Shamim O'Brien | 14 — টেবিলে জমা হয়েছে একটিমাত্র ', দৈর্ঘ্য ১৪. '' শুধু লেখার নিয়ম, ডেটার অংশ নয়। শেষ INSERT-এ ত্রুটি: near "Brien": syntax error — ডায়াগ্রামের চতুর্থ ধাপ। তাহলে কি অ্যাপে প্রতিটি ইনপুটের ' দ্বিগুণ করে দিলেই চলবে? না — অংশ ৩-এ দেখবেন, হাতে escape করা ভঙ্গুর; কাজটি driver-কে দিন।

এবার একই জিনিস একটি অ্যাপের ভেতর থেকে। নিচের Python প্রোগ্রাম (stdlib-এর sqlite3, মেমরিতে একটি ছোট নিজস্ব টেবিল) দুটি খোঁজ-ফাংশন বানায় — একটি জোড়া দিয়ে, একটি placeholder দিয়ে — আর দুটি নাম দিয়ে চালায়:

import sqlite3

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, city TEXT)")
db.executemany("INSERT INTO customers (name, city) VALUES (?, ?)",
    [("Rahim Uddin", "Dhaka"), ("Shamim O'Brien", "Sylhet"), ("Karima Begum", "Chattogram")])

def find_glued(name):      # DON'T: input becomes part of the SQL text
    sql = "SELECT id, city FROM customers WHERE name = '" + name + "'"
    return db.execute(sql).fetchall()

def find_bound(name):      # DO: fixed SQL text, value sent separately
    return db.execute("SELECT id, city FROM customers WHERE name = ?", (name,)).fetchall()

for name in ["Rahim Uddin", "Shamim O'Brien"]:
    try:
        glued = find_glued(name)
    except sqlite3.Error as e:
        glued = "ERROR: " + str(e)
    print(repr(name), "| glued:", glued, "| bound:", find_bound(name))

আউটপুট দুই লাইন: 'Rahim Uddin' | glued: [(1, 'Dhaka')] | bound: [(1, 'Dhaka')] আর "Shamim O'Brien" | glued: ERROR: near "Brien": syntax error | bound: [(2, 'Sylhet')]. সাধারণ নামে দুটি ফাংশন একই — এজন্যই জোড়া-দেওয়া কোড test-এ পাস করে যায়। '-ওয়ালা নামে জোড়া-দেওয়া ফাংশন ভেঙে পড়ে, placeholder-ওয়ালাটি ঠিক কাজ করে।

ইতিহাস এক নজরে। SQL injection-এর প্রথম পরিচিত প্রকাশ্য বিবরণ ১৯৯৮ সালের ডিসেম্বরে, Phrack ম্যাগাজিনের ৫৪তম সংখ্যায় — Jeff Forristal-এর লেখায়। তারপর প্রায় তিন দশক পার হয়েছে, তবু ওয়েব নিরাপত্তার পরিচিত তালিকা OWASP Top 10-এ “Injection” ২০১০, ২০১৩ আর ২০১৭-এ প্রথম স্থানে ছিল, ২০২১-এর তালিকায় তৃতীয়। প্রতিকার বহু বছর ধরে জানা; সমস্যা টিকে আছে কারণ জোড়া দেওয়ার পথটি সবসময় সবচেয়ে সহজ দেখায়।

৩ · প্রতিরোধ — parameterised query, allow-list আর নিরাপদ dynamic SQL

প্রধান প্রতিরোধ: parameterised query

Parameterised query-তে SQL-এর লেখায় মানের জায়গায় placeholder থাকে — ? (SQLite, JDBC, MySQL), $1 (PostgreSQL), বা :name / %s (কিছু driver). মানগুলো যায় আলাদা তালিকায়। সার্ভার যখন লেখাটি আগে parse করে রাখে আর পরে বারবার আলাদা মান দিয়ে চালায়, তাকে বলে prepared statement (পর্ব ৪৯)। সব জনপ্রিয় ভাষায় একই ধারণা:

# sqlite3: ?   ·   psycopg (PostgreSQL): %s — দুটিই placeholder
cur.execute("SELECT id, city FROM customers WHERE name = ?", (name,))

# ভুল: f-string বা + দিয়ে জোড়া, placeholder-এর মতো দেখালেও
cur.execute(f"SELECT id, city FROM customers WHERE name = '{name}'")
String sql = "SELECT id, city FROM customers WHERE name = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
    ps.setString(1, name);              // ১ নম্বর ? = name
    try (ResultSet rs = ps.executeQuery()) {
        while (rs.next()) { /* ... */ }
    }
}
const { rows } = await pool.query(
  'SELECT id, city FROM customers WHERE name = $1',
  [name]                                // $1 = name
);
$st = $pdo->prepare('SELECT id, city FROM customers WHERE name = :name');
$st->execute(['name' => $name]);
$rows = $st->fetchAll();

কেন এটি “মূল থেকে” সমাধান? কারণ গঠন আর মান দুটি আলাদা চ্যানেলে যায়। মানের ভেতরে কী আছে — ', ;, --, বাংলা অক্ষর, emoji — তা দেখার প্রয়োজনই পড়ে না, কোনো তালিকা মেলাতে হয় না। “খারাপ অক্ষর খুঁজে বাদ দাও” ধরনের পদ্ধতি কোনো না কোনো রূপ বাদ রাখবেই; placeholder সেই খেলাতেই নামে না।

ORM আর query builder — সঠিকভাবে ব্যবহার করলে

পর্ব ৫০-এর ORM (SQLAlchemy, Django ORM, Hibernate, Eloquent) নিজে থেকেই placeholder দিয়ে SQL বানায়: select(Customer).where(Customer.name == name) নিরাপদ। বিপদ ORM-এর “কাঁচা SQL” দরজায়: SQLAlchemy-র text(), Django-র raw() বা extra(), Hibernate-এর HQL/native query — এগুলোর ভেতরে f-string বা জোড়া দিলে ORM কোনো রক্ষা করে না। নিয়ম একই: কাঁচা SQL-এও bind parameter — text("… WHERE name = :n")-এর সাথে {"n": name}.

যা placeholder হতে পারে না — identifier আর allow-list

Placeholder শুধু মানের জায়গায় বসে। টেবিল বা কলামের নাম (identifier), ASC/DESC, SQL-এর কোনো keyword — এগুলো কোয়েরির গঠনের অংশ, তাই parse-এর আগেই জানা চাই। অথচ “দাম অনুযায়ী সাজাও” বা “নাম অনুযায়ী সাজাও” — এমন বোতাম প্রায় প্রতিটি অ্যাপে আছে। সমাধান allow-list: ব্যবহারকারী শুধু একটি চাবি পাঠান, আর কোডে প্রোগ্রামারের লেখা একটি তালিকা থেকে আসল SQL-এর টুকরো বেছে নেওয়া হয়। তালিকায় না থাকলে সোজা প্রত্যাখ্যান। নিচের প্রোগ্রামে এটি, সাথে একটি নীরব ফাঁদ:

import sqlite3

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, city TEXT)")
db.executemany("INSERT INTO customers (name, city) VALUES (?, ?)",
    [("Rahim Uddin", "Dhaka"), ("Shamim O'Brien", "Sylhet"), ("Karima Begum", "Chattogram")])

# 1) a placeholder is a VALUE, not a column: ORDER BY 'name' sorts nothing
print([r[0] for r in db.execute("SELECT name FROM customers ORDER BY ?", ("name",))])

# 2) identifiers come from an allow-list written by the programmer
SORTS = {"name": "name", "city": "city", "newest": "id DESC"}
def list_customers(sort_key):
    if sort_key not in SORTS:
        raise ValueError("unknown sort key: " + repr(sort_key))
    return [r[0] for r in db.execute("SELECT name FROM customers ORDER BY " + SORTS[sort_key])]

print(list_customers("city"))
try:
    list_customers("phone")
except ValueError as e:
    print(e)

প্রথম লাইন ['Rahim Uddin', "Shamim O'Brien", 'Karima Begum'] — সাজানো হয়নি! ORDER BY ?-এ placeholder একটি ধ্রুব মান 'name' হয়ে গেছে, কলাম নয়; সব সারির “সাজানোর চাবি” একই, তাই যে ক্রমে এল সেই ক্রমেই (SQLite-এ এখানে ঢোকানোর ক্রম, কিন্তু তার কোনো নিশ্চয়তা নেই)। কোনো ত্রুটি আসেনি — এটি নীরব বাগ। দ্বিতীয় লাইন ['Karima Begum', 'Rahim Uddin', "Shamim O'Brien"] — শহর অনুযায়ী (Chattogram, Dhaka, Sylhet). তৃতীয় লাইন unknown sort key: 'phone' — তালিকায় নেই, তাই SQL পর্যন্ত পৌঁছায়ইনি।

আরেকটি সূক্ষ্ম ফাঁদ second-order injection: মানটি জমা হয়েছিল নিরাপদে, placeholder দিয়ে — যেমন ওপরের O'Brien. কিন্তু পরে অন্য কোনো স্ক্রিপ্ট — রাতের রিপোর্ট, অ্যাডমিন প্যানেল — ডেটাবেস থেকে পড়া মানকে “নিজেদের ডেটা, বিশ্বাসযোগ্য” ভেবে জোড়া দিল — অংশ ২-এর সেই একই ভাঙন, এবার অন্য জায়গায়। ইনপুট যেখান থেকেই আসুক — ফর্ম, ফাইল, অন্য টেবিল, অন্য সার্ভিস — SQL-এ ঢোকার মুহূর্তে placeholder.

Stored procedure-এর ভেতরে dynamic SQL

পর্ব ২৮-এ দেখেছেন, stored procedure-এর ভেতরে চলার সময় SQL বানানো যায় — dynamic SQL. procedure থাকলেই নিরাপদ নয়: ভেতরে EXECUTE 'SELECT … ORDER BY ' || sort_col লিখলে একই জোড়ার সমস্যা ডেটাবেসের ভেতরে চলে আসে। PostgreSQL-এর নিরাপদ পথ তিনটি অংশে: allow-list, identifier-এর জন্য format('%I', …) (বা quote_ident), আর মানের জন্য EXECUTE … USING:

CREATE FUNCTION products_sorted(sort_col text, max_rows int)
RETURNS SETOF products
LANGUAGE plpgsql AS $$
BEGIN
  IF sort_col NOT IN ('name', 'price', 'stock') THEN      -- allow-list
    RAISE EXCEPTION 'sort column not allowed: %', sort_col;
  END IF;
  RETURN QUERY EXECUTE
    format('SELECT * FROM products ORDER BY %I LIMIT $1', sort_col)  -- %I = identifier
    USING max_rows;                                        -- মান = parameter
END;
$$;

%I নামটিকে প্রয়োজনে double quote-এ মুড়ে নিরাপদ identifier বানায় (quoted identifier); %L একটি মানকে নিরাপদ literal বানায়, কিন্তু মানের জন্য USING আরও ভালো। অন্য DBMS-এ একই কাজ: SQL Server-এ sp_executesql (parameter-সহ) আর identifier-এর জন্য QUOTENAME(); MySQL-এ PREPARE … FROM আর EXECUTE … USING @v. procedure-টি SECURITY DEFINER হলে (মালিকের অধিকারে চলে) সতর্কতা আরও বেশি দরকার, কারণ ভুলের ক্ষতি হবে মালিকের অধিকার পর্যন্ত; PostgreSQL ডকুমেন্টেশন এমন function-এ search_path নির্দিষ্ট করে দিতেও বলে।

সহায়ক স্তর: input validation আর নিরাপদ ত্রুটি-বার্তা

Input validation — “মোবাইল নম্বর ১১ অঙ্কের, 01 দিয়ে শুরু”, “পরিমাণ ১ থেকে ১০০” — ভালো অভ্যাস: ভুল ডেটা আগেই আটকায়, ব্যবহারকারীকে স্পষ্ট বার্তা দেয়। কিন্তু এটি injection-এর সমাধান নয়। নাম, ঠিকানা, মন্তব্যের মতো মুক্ত লেখায় ' বৈধ (O'Brien!), আর একটি ফর্মে validation ভুলে গেলেই ফাঁক। তাই validation হলো defence in depth-এর একটি স্তর — placeholder-এর পাশে, বদলে নয়।

ত্রুটি-বার্তাও তথ্য ফাঁস করে। ডেটাবেসের কাঁচা বার্তা — টেবিলের নাম, কলামের নাম, SQL-এর টুকরো, DBMS-এর সংস্করণ — ব্যবহারকারীর পর্দায় দেখালে schema-র মানচিত্র বাইরে চলে যায়। নিয়ম: ব্যবহারকারী দেখবেন সাধারণ বার্তা আর একটি রেফারেন্স কোড (“কিছু সমস্যা হয়েছে, কোড E-7F3A”); পুরো বিবরণ যাবে সার্ভারের log-এ, যা শুধু দলের লোক পড়েন।

নিজে escape করবেন না। “'-কে '' বানিয়ে দিলেই হলো” — এই পথে বহু ফাঁক: সংখ্যার কলামে উদ্ধৃতিচিহ্নই থাকে না, MySQL-এ backslash-এর আলাদা নিয়ম আছে, connection-এর character set না মিললে escape ব্যর্থ হওয়ার পরিচিত ঘটনা আছে, আর হাজার জায়গার একটিতে ভুলে গেলেই হলো। escape করার কাজ driver-এর — placeholder দিলে সে নিজেই সঠিকভাবে করে, বা আলাদা চ্যানেলে পাঠায়।

আরও গভীরে: PostgreSQL-এর wire protocol-এ “extended query” পথে Parse (placeholder-ওয়ালা লেখা), Bind (মানগুলো, আলাদা বার্তায়) আর Execute — তিনটি আলাদা ধাপ; MySQL-এ একই কাজ COM_STMT_PREPARE আর COM_STMT_EXECUTE. সব driver অবশ্য সার্ভারে আলাদা পাঠায় না: PHP-এর PDO MySQL-এর জন্য ডিফল্টে prepare “emulate” করে, আর psycopg2 মানগুলো নিজেই নিরাপদে quote করে লেখায় বসায় (psycopg 3 ডিফল্টে সার্ভার-পক্ষের binding ব্যবহার করে)। এগুলোও নিরাপদ, কারণ quote করার কাজ driver-এর পরীক্ষিত কোড করে, আপনার হাতে লেখা কোড নয় — শর্ত হলো connection-এর character set ঠিকভাবে বলে দেওয়া।

৪ · Authentication — ডেটাবেসে কে ঢুকছেন

নিরাপত্তার দুটি আলাদা প্রশ্ন আছে, যা প্রায়ই গুলিয়ে যায়। Authentication: “আপনি কে?” — পরিচয় প্রমাণ। Authorization: “আপনি কী করতে পারেন?” — অধিকার। এই অংশ প্রথমটি নিয়ে।

  • প্রত্যেকের আলাদা অ্যাকাউন্ট। পাঁচজন ডেভেলপার একটি “admin” password ভাগ করে নিলে audit log-এ শুধু “admin” লেখা থাকবে — কে করলেন, জানার উপায় নেই; কেউ চাকরি ছাড়লে সবার password বদলাতে হয়। মানুষ প্রতি একটি, অ্যাপ বা সার্ভিস প্রতি একটি অ্যাকাউন্ট।
  • শক্ত password, নিরাপদ পদ্ধতিতে যাচাই। অ্যাপের অ্যাকাউন্টে লম্বা, এলোমেলো password (মানুষকে মনে রাখতে হয় না)। PostgreSQL ১০ থেকে SCRAM-SHA-256 আছে, আর ১৪ থেকে এটিই ডিফল্ট: সার্ভার password নিজে রাখে না, আর নেটওয়ার্কে password যায় না — দুই পক্ষ একটি challenge-response বিনিময়ে প্রমাণ করে যে দুজনেই একই গোপন জিনিস জানে। পুরোনো MD5 পদ্ধতি এড়িয়ে চলুন; সাম্প্রতিক সংস্করণে (PostgreSQL ১৮) এটিকে deprecated ঘোষণা করা হয়েছে। MySQL ৮-এর ডিফল্ট caching_sha2_password.
  • কোথা থেকে ঢোকা যাবে, তা-ও নিয়ম। ডেটাবেসের পোর্ট (PostgreSQL 5432, MySQL 3306) কখনো সরাসরি ইন্টারনেটে খোলা রাখবেন না; শুধু অ্যাপ সার্ভারের ঠিকানা থেকে। PostgreSQL-এ এই নিয়ম pg_hba.conf-এ — ওপর থেকে প্রথম মেলা লাইনটিই প্রযোজ্য।
  • গোপন তথ্য কোডে নয়। connection string-এর password কোডে বা Git-এ নয় — environment variable বা একটি secrets manager-এ। নির্দিষ্ট সময় পরপর, আর কেউ চলে গেলে বা ফাঁসের সন্দেহ হলে সঙ্গে সঙ্গে, password বদলান (rotation).
# TYPE    DATABASE  USER         ADDRESS        METHOD
hostssl   shop      app_web      10.0.1.0/24    scram-sha-256
hostssl   shop      +shop_admin  10.0.9.5/32    scram-sha-256
host      all       all          0.0.0.0/0      reject

প্রথম লাইন: শুধু TLS সংযোগে (hostssl), শুধু shop ডেটাবেসে, শুধু app_web, শুধু অ্যাপ সার্ভারের নেটওয়ার্ক থেকে। দ্বিতীয়: +shop_admin মানে ঐ role-এর সদস্যরা, একটি নির্দিষ্ট অ্যাডমিন যন্ত্র থেকে। শেষ লাইন বাকি সব বাতিল করে। SQLite-এর কোনো ব্যবহারকারী বা password নেই — সেখানে পাহারা হলো ফাইলের অধিকার: যে প্রক্রিয়া ফাইলটি পড়তে পারে, সে সব ডেটা পড়তে পারে।

৫ · Authorization — role, GRANT, REVOKE আর least privilege

লগইনের পরে প্রশ্ন: এই অ্যাকাউন্ট কোন টেবিলে কী করতে পারবে? SQL-এর উত্তর privilege — একটি নির্দিষ্ট বস্তুর ওপর একটি নির্দিষ্ট কাজের অনুমতি: SELECT, INSERT, UPDATE, DELETE (টেবিল, view বা নির্দিষ্ট কলামে), EXECUTE (function-এ), USAGE (schema-য়), CREATE, CONNECT ইত্যাদি। দেওয়া হয় GRANT দিয়ে, ফেরানো হয় REVOKE দিয়ে। আধুনিক DBMS-এ অধিকার সরাসরি মানুষকে না দিয়ে দেওয়া হয় role-কে — “কাজের পদ”, যেমন “শুধু পড়ে”, “অর্ডার লেখে”, “অ্যাডমিন” — আর মানুষ বা অ্যাপকে সেই role-এর সদস্য করা হয়। PostgreSQL-এ user আর role একই জিনিস (LOGIN-ওয়ালা role = user); MySQL-এ role এসেছে ৮.০-তে।

পেছনের নীতিটির নাম principle of least privilege: প্রতিটি অ্যাকাউন্ট পাবে কাজের জন্য যতটুকু দরকার, ঠিক ততটুকু — তার বেশি নয়। অংশ ২-এর বাগটি যদি কোনোদিন থেকে যায়, ক্ষতির সীমা ঠিক করে দেবে এই নীতি। নিচের ডায়াগ্রামে PostgreSQL-এ একটি অধিকারের শিকল ধাপে ধাপে দেখুন:

  1. app_webLOGINঅ্যাপের অ্যাকাউন্ট
  2. shop_readNOLOGINশুধু পড়া
  3. shop_writeNOLOGINঅর্ডার লেখা
  4. সদস্যপদGRANT … TOrole-এর অধিকার পাওয়া
অ্যাডমিন চালালেনশুরু শূন্য থেকে
CREATE ROLE app_web LOGIN;
\password app_web       -- psql-এ, log-এ password যায় না

app_web কোনো টেবিলে কিছুই পারে না
পড়ার দলনিজে লগইন করতে পারে না
CREATE ROLE shop_read NOLOGIN;
GRANT USAGE ON SCHEMA public TO shop_read;
GRANT SELECT ON products, categories,
      customers_public TO shop_read;
লেখার দলপড়ার দলের ওপর গড়া
CREATE ROLE shop_write NOLOGIN;
GRANT shop_read TO shop_write;
GRANT SELECT, INSERT, UPDATE
      ON orders, order_items TO shop_write;
অ্যাপকে দলে যোগঅধিকার উত্তরাধিকারে আসে
GRANT shop_write TO app_web;

app_web ⊃ shop_write ⊃ shop_read
app_web হিসেবে পরীক্ষাযা দেওয়া হয়নি, তা নেই
SELECT name, city FROM customers_public;  -- ✓
INSERT INTO orders …;                     -- ✓
SELECT phone FROM customers;
ERROR: permission denied for table customers
DROP TABLE orders;
ERROR: must be owner of table orders
অধিকার ফেরানোএক লাইনে
REVOKE shop_write FROM app_web;
GRANT  shop_read  TO   app_web;   -- শুধু পড়া

INSERT INTO orders …;
ERROR: permission denied for table orders
app_web কী পারে
products পড়া
orders-এ লেখা
customers.phone পড়া
DROP / ALTER
  1. শূন্য থেকে শুরু। নতুন লগইন role-এর কোনো টেবিলে কোনো অধিকার নেই। password দেওয়া হলো psql-এর \password দিয়ে, যাতে লেখাটি SQL-এর log-এ বা history-তে না থাকে।
  2. পড়ার দল। shop_read একটি NOLOGIN role — কেউ এই নামে লগইন করে না, এটি শুধু অধিকারের থলি। এটি পণ্য আর customers_public view পড়তে পারে, আসল customers টেবিল নয়। app_web এখনো সদস্য নয়, তাই তার অধিকার বদলায়নি।
  3. লেখার দল। shop_write প্রথমে shop_read-এর সদস্য হলো (সব পড়ার অধিকার পেল), তারপর অর্ডারের দুটি টেবিলে লেখার অধিকার। role-এর ভেতরে role — অধিকার স্তরে স্তরে সাজানো।
  4. সদস্যপদ। app_web shop_write-এর সদস্য হতেই শিকল বেয়ে দুই দলের অধিকারই পেল।
  5. পরীক্ষা। যা দেওয়া হয়েছে তা চলে। আসল customers টেবিল (phone-সহ) পড়া যায় না, আর টেবিল মোছা বা বদলানো শুধু মালিকের কাজ। অ্যাপের কোনো বাগ দিয়ে কোয়েরি বদলে গেলেও, ডেটাবেস এই সীমার বাইরে কিছু করতে দেবে না।
  6. ফেরানো। একটি REVOKE-এ লেখার পুরো দল সরে গেল; app_web এখন শুধু পড়ার দলে, তাই INSERT-এ ত্রুটি। একটি টেবিলের অধিকার নানা পথে (সরাসরি, আর role-এর মাধ্যমে) আসতে পারে — একটি পথ ফেরালে অন্যটি থেকে যায়, তাই REVOKE-এর পর পরীক্ষা করুন।

বাস্তবে সাধারণত অন্তত তিনটি আলাদা role থাকে: অ্যাপ (শুধু নিজের টেবিলে পড়া-লেখা, কোনো DDL নয়), রিপোর্ট বা analyst (শুধু পড়া, সম্ভব হলে replica-য়), আর অ্যাডমিন/migration (DDL, শুধু deploy-এর সময়, আলাদা অ্যাকাউন্টে)। টেবিলের মালিক কোনো অ্যাপের অ্যাকাউন্ট নয়। কলাম ধরে অধিকারও দেওয়া যায়, PostgreSQL আর MySQL দুটিতেই:

-- কলাম-স্তরের অধিকার: phone ছাড়া পড়া, শুধু city বদলানো
GRANT SELECT (customer_id, name, city), UPDATE (city)
      ON customers TO support_team;

REVOKE ALL ON customers FROM PUBLIC;          -- "সবাই" থেকে সব ফেরানো
CREATE ROLE report_bot LOGIN IN ROLE shop_read;

-- কে কী পেয়েছে, দেখা
SELECT grantee, privilege_type
FROM information_schema.role_table_grants
WHERE table_name = 'orders';
CREATE USER 'app_web'@'10.0.1.%' IDENTIFIED BY '…';   -- কোথা থেকে, তা-ও অংশ
CREATE ROLE 'shop_read', 'shop_write';
GRANT SELECT ON shop.products TO 'shop_read';
GRANT SELECT (customer_id, name, city) ON shop.customers TO 'shop_read';
GRANT 'shop_read' TO 'shop_write';
GRANT INSERT, UPDATE ON shop.orders TO 'shop_write';
GRANT 'shop_write' TO 'app_web'@'10.0.1.%';
SET DEFAULT ROLE ALL TO 'app_web'@'10.0.1.%';   -- না দিলে লগইনে role সক্রিয় হয় না
SHOW GRANTS FOR 'app_web'@'10.0.1.%';

PostgreSQL-এ PUBLIC মানে “সব role”. ১৫-এর আগের সংস্করণে public schema-য় সবাই টেবিল বানাতে পারত; নতুন ডেটাবেসে এটি আর ডিফল্ট নয়, পুরোনো ডেটাবেসে REVOKE CREATE ON SCHEMA public FROM PUBLIC চালিয়ে নিন।

View দিয়ে কলাম লুকানো

কলাম-স্তরের GRANT-এর আরেকটি পরিচিত পথ view (পর্ব ২৪): শুধু দরকারি কলামগুলো নিয়ে একটি view বানিয়ে সেটির ওপর SELECT দেওয়া, মূল টেবিলের ওপর নয়। PostgreSQL-এ ডিফল্টে view চলে তার মালিকের অধিকারে, তাই পড়ুয়াকে মূল টেবিলের অধিকার দিতে হয় না (১৫ থেকে security_invoker দিয়ে উল্টোটাও বেছে নেওয়া যায়)। SQLite-এ অধিকার নেই, কিন্তু ধারণাটি দেখা যায় — সাপোর্ট টিমের জন্য phone ছাড়া গ্রাহকের তালিকা:

CREATE VIEW customers_public AS
SELECT customer_id, name, city FROM customers;
SELECT * FROM customers_public WHERE city = 'Dhaka';
-- view-এ phone কলামটিই নেই
SELECT name, phone FROM customers_public;

“✓ CREATE VIEW”, তারপর ঢাকার তিনজন: 1 | Rahim Uddin | Dhaka, 3 | Sabbir Hasan | Dhaka, 8 | Mitu Rani | Dhaka. শেষ SELECT-এ ত্রুটি no such column: phone — view-এর জগতে phone বলে কিছু নেই। PostgreSQL বা MySQL-এ সাপোর্ট টিমের role-কে শুধু GRANT SELECT ON customers_public দিলে, মূল টেবিল ছুঁতেই পারবে না।

৬ · Row-level security — প্রতিটি দোকান শুধু নিজের সারি

কলাম লুকানো গেল; সারি? ধরুন আপনি দোকানের হিসাবের একটি অনলাইন সফটওয়্যার চালান, যেটি ঢাকা, খুলনা, সিলেটের শত শত দোকান ব্যবহার করে — সবার ডেটা একই টেবিলে, একটি shop_id কলাম দিয়ে আলাদা। একে বলে multi-tenant নকশা। সবচেয়ে বড় ভয়: কোনো একটি কোয়েরিতে WHERE shop_id = … লিখতে ভুলে গেলে এক দোকান অন্য দোকানের বিক্রি দেখে ফেলবে। Row-level security (RLS) এই শর্তটি অ্যাপের প্রতিটি কোয়েরি থেকে সরিয়ে টেবিলের নিজের নিয়মে বসায় — PostgreSQL-এ ৯.৫ (২০১৬) থেকে:

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;   -- টেবিলের মালিকও নিয়মের অধীনে

CREATE POLICY shop_isolation ON orders
  USING      (shop_id = current_setting('app.shop_id')::int)   -- কোন সারি দেখা/বদলানো যাবে
  WITH CHECK (shop_id = current_setting('app.shop_id')::int);  -- নতুন/বদলানো সারি কেমন হতে হবে

-- অ্যাপ প্রতিটি transaction-এর শুরুতে, লগইন করা দোকানের id বসায়
BEGIN;
SET LOCAL app.shop_id = '42';
SELECT order_id, status FROM orders;   -- DBMS নিজে যোগ করে: AND shop_id = 42
COMMIT;

খুঁটিনাটিগুলো জরুরি। RLS চালু থাকলে কোনো policy না থাকলে কোনো সারিই দেখা যায় না — ভুল হলে দরজা বন্ধ থাকে, খোলা নয়। superuser আর BYPASSRLS-ওয়ালা role নিয়ম এড়ায়, আর টেবিলের মালিকও এড়ায় যদি না FORCE দেওয়া থাকে — তাই অ্যাপ কখনো মালিক বা superuser হিসেবে সংযোগ করবে না। SET LOCAL মানটি শুধু ঐ transaction পর্যন্ত রাখে; connection pool-এ (পর্ব ৪৯) একটি সংযোগ পরের অনুরোধে অন্য দোকানের কাজে গেলে পুরোনো মান থেকে যাওয়ার ঝুঁকি এভাবে কমে। আর সীমাটি সৎভাবে মনে রাখুন: RLS অ্যাপের ভুলে-যাওয়া WHERE-এর বিরুদ্ধে শক্ত পাহারা; কিন্তু অ্যাপ নিজেই যদি যেকোনো SQL চালানোর মতো আক্রান্ত হয়, সে app.shop_id-ও বদলাতে পারে। তখন বাঁচায় অন্য স্তরগুলো।

SQLite-এ RLS নেই, কিন্তু মূল ধারণাটি — “বর্তমান ব্যবহারকারী একটি জায়গায় লেখা, আর view সেই অনুযায়ী সারি ছাঁকে” — দেখা যায়। এখানে app_session টেবিলটি PostgreSQL-এর app.shop_id-এর ভূমিকা নিচ্ছে, আর গ্রাহকেরা tenant:

CREATE TABLE app_session (customer_id INTEGER NOT NULL);
INSERT INTO app_session VALUES (1);          -- লগইন: Rahim
CREATE VIEW my_orders AS
SELECT o.order_id, o.order_date, o.status
FROM orders o JOIN app_session s ON s.customer_id = o.customer_id;
SELECT * FROM my_orders;
UPDATE app_session SET customer_id = 2;      -- লগইন: Karima
SELECT * FROM my_orders;

প্রথম SELECT Rahim-এর তিনটি অর্ডার দেয়: 1001 | 2025-09-01 | delivered, 1003 | 2025-09-10 | shipped, 1009 | 2025-10-08 | pending. session বদলাতেই একই কোয়েরি Karima-র দুটি: 1002 | 2025-09-03 | delivered আর 1007 | 2025-10-01 | delivered. কোয়েরিতে কোনো WHERE customer_id নেই — ছাঁকনিটি view-এর ভেতরে, এক জায়গায়। পার্থক্য: SQLite-এ যে কেউ মূল orders টেবিলও পড়তে পারে; PostgreSQL-এর RLS মূল টেবিলেই নিয়ম বসায়, তাই পাশ কাটানোর পথ থাকে না।

৭ · Encryption আর password hashing

অধিকার ঠিক করে দেয় কে ডেটাবেসের ভেতর দিয়ে কী পড়বে। কিন্তু ডেটা ডেটাবেসের বাইরেও থাকে — নেটওয়ার্কের তারে, ডিস্কে, backup ফাইলে। সেখানে পাহারা encryption: key ছাড়া পড়া যায় না এমন রূপে বদলে রাখা।

কোথায়কীভাবেকী থেকে বাঁচায়কী থেকে বাঁচায় না
পথে (in transit)TLS; PostgreSQL client-এ sslmode=verify-full, MySQL-এ require_secure_transportনেটওয়ার্কে আড়ি পাতা, মাঝপথে অন্য সার্ভারের ছদ্মবেশডেটাবেসের ভেতরের ভুল অধিকার
ডিস্কে (at rest)ডিস্ক বা ফাইল-সিস্টেম encryption; SQL Server, Oracle, MySQL-এর TDE; cloud-এর storage encryptionচুরি যাওয়া ডিস্ক, ল্যাপটপ, ফেলে দেওয়া হার্ডড্রাইভ, backup ফাইলSQL দিয়ে পড়া — ডেটাবেস নিজেই খুলে দেয়
কলামেঅ্যাপে encrypt করে জমা (বা PostgreSQL-এর pgcrypto)DBA, backup, এমনকি SQL দিয়ে পড়া — key ছাড়া অর্থহীনkey যার হাতে, তার কাছ থেকে; খোঁজা ও index কঠিন হয়

PostgreSQL-এর মূল সংস্করণে TDE নেই — সাধারণত ডিস্ক-স্তরের encryption ব্যবহার হয়, কিছু বাণিজ্যিক সংস্করণে TDE আছে। কলাম-স্তরের encryption রাখুন সবচেয়ে স্পর্শকাতর অল্প কয়েকটি কলামে: NID নম্বর, ব্যাংক অ্যাকাউন্ট, স্বাস্থ্যের তথ্য। সবচেয়ে ভালো হলো অ্যাপেই encrypt করা, যাতে key কখনো ডেটাবেসে না যায় — key থাকবে আলাদা key management সেবায়। ডেটাবেসে encrypt করলে (যেমন pgp_sym_encrypt) key কোয়েরির সাথে যায়, আর statement log চালু থাকলে log-এ উঠে যেতে পারে।

Password — encrypt নয়, hash

ব্যবহারকারীর password কখনো সাধারণ লেখায় জমা রাখবেন না, encrypt করেও নয় — encrypt মানে key থাকলে ফেরত পড়া যায়। জমা রাখুন password hash: একমুখী একটি হিসাব, যার ফল থেকে password ফেরত পাওয়া যায় না; লগইনের সময় নতুন করে hash বানিয়ে মেলানো হয়। তিনটি শর্ত: প্রতিটি ব্যবহারকারীর আলাদা এলোমেলো salt (একই password-এর hash আলাদা হয়, আগে-বানানো তালিকা অকেজো), ইচ্ছা করে ধীর আর মেমরি-খেকো অ্যালগরিদম (লাখো অনুমান যাচাই ব্যয়বহুল হয়), আর প্রমাণিত library. SHA-256 বা MD5 একা চলবে না — এগুলো দ্রুত হওয়ার জন্যই বানানো। এখনকার পছন্দ Argon2id, বা পুরোনো ও পরীক্ষিত bcrypt; scrypt আর PBKDF2-ও চলে। কাজটি অ্যাপে হয়, ডেটাবেস শুধু ফলটি রাখে:

from argon2 import PasswordHasher
ph = PasswordHasher()                       # salt নিজেই বানায়, hash-এর ভেতরে রাখে

stored = ph.hash("Shapla#2025")             # '$argon2id$v=19$m=65536,t=3,p=4$...'
cur.execute("UPDATE staff SET pw_hash = ? WHERE username = ?", (stored, "rahim"))

ph.verify(stored, "Shapla#2025")            # True; ভুল হলে VerifyMismatchError

৮ · Audit, backup আর defence in depth

কোনো পাহারাই নিখুঁত নয়, তাই দুটি প্রশ্নের উত্তর তৈরি রাখতে হয়: কিছু ঘটলে জানব কীভাবে? আর ঘটে গেলে ফিরব কীভাবে? প্রথমটির উত্তর audit log — কে, কখন, কোন সারিতে কী করল। পর্ব ২৯-এর trigger দিয়ে ছোট একটি audit ব্যবস্থা: গ্রাহকের phone বদলালে বা গ্রাহক মুছলে লেখা থাকবে। খেয়াল করুন, log নিজেও স্পর্শকাতর — তাই পুরো নম্বর নয়, শুধু শেষ তিন অঙ্ক রাখা হচ্ছে। এই এডিটরে আগে থেকে একটি খালি audit_log টেবিল আছে (log_id, table_name, action, row_id, detail, at — শেষটির DEFAULT লেখার মুহূর্তের সময়):

CREATE TRIGGER trg_audit_phone AFTER UPDATE OF phone ON customers
WHEN NEW.phone IS NOT OLD.phone
BEGIN
  INSERT INTO audit_log (table_name, action, row_id, detail)
  VALUES ('customers', 'UPDATE phone', OLD.customer_id,
    coalesce('…' || substr(OLD.phone, -3), 'none') || ' -> …' || substr(NEW.phone, -3));
END;
CREATE TRIGGER trg_audit_delete AFTER DELETE ON customers
BEGIN
  INSERT INTO audit_log (table_name, action, row_id) VALUES ('customers', 'DELETE', OLD.customer_id);
END;
UPDATE customers SET phone = '01711999999' WHERE customer_id = 1;
UPDATE customers SET phone = '01511000555' WHERE customer_id = 5;
DELETE FROM customers WHERE customer_id = 5;
SELECT log_id, action, row_id, detail, at IS NOT NULL AS has_time FROM audit_log;

তিনটি পরিবর্তনের পর log-এ তিনটি সারি: 1 | UPDATE phone | 1 | …001 -> …999 | 1, 2 | UPDATE phone | 5 | none -> …555 | 1 আর 3 | DELETE | 5 | NULL | 1. দ্বিতীয়টিতে Tanvir-এর phone ছিল NULL; শর্তে <>-এর বদলে IS NOT থাকায় NULL থেকে নম্বরে বদলও ধরা পড়েছে (পর্ব ২৯-এর NULL-এর ফাঁদ)। Tanvir-এর কোনো অর্ডার নেই, তাই মোছা foreign key-তে আটকায়নি। আসল ব্যবস্থায় “কে” কলামও লাগে — SQLite-এ ব্যবহারকারী নেই; PostgreSQL-এ current_user, আর অ্যাপের ব্যক্তির জন্য session ভেরিয়েবল।

বড় সিস্টেমে trigger-এর পাশাপাশি DBMS-এর নিজের audit ব্যবস্থা থাকে। PostgreSQL-এ pgaudit extension নির্দিষ্ট শ্রেণির স্টেটমেন্ট — DDL, role-এর বদল, লেখা, পড়া — সার্ভারের log-এ লেখে; সাথে log_connections দিয়ে প্রতিটি লগইন। log পাঠাতে হয় এমন জায়গায়, যেখানে ডেটাবেসের অ্যাডমিনও মুছতে পারেন না — নইলে log প্রমাণ হিসেবে দুর্বল।

shared_preload_libraries = 'pgaudit'
pgaudit.log = 'ddl, role, write'      # টেবিল বদল, অধিকার বদল, সব লেখা
log_connections = on
log_disconnections = on

দ্বিতীয় প্রশ্ন — ফিরব কীভাবে — তার উত্তর backup. এটি শুধু ডিস্ক নষ্ট হওয়ার জন্য নয়, নিরাপত্তারও অংশ: কেউ ডেটা মুছে দিলে, ভুল UPDATE-এ হাজার সারি নষ্ট হলে, বা ransomware সার্ভারের ফাইল encrypt করে মুক্তিপণ চাইলে — ভালো backup-ই শেষ ভরসা। শর্ত তিনটি: অন্তত একটি কপি মূল সিস্টেম থেকে আলাদা আর মুছতে-না-পারা জায়গায় (যে অ্যাকাউন্ট ডেটাবেস চালায়, সে যেন backup মুছতে না পারে), backup নিজেও encrypt করা, আর নিয়মিত ফিরিয়ে এনে পরীক্ষা — কখনো restore না-করা backup আসলে একটি আশা মাত্র। নির্দিষ্ট মুহূর্তে ফেরার point-in-time recovery, replication আর তাদের সীমা বিস্তারিত পর্ব ৫২-এ।

এবার সব স্তর একসাথে। ধরুন একটি পুরোনো রিপোর্ট পাতায় কোনোভাবে স্ট্রিং জোড়া থেকে গেছে। স্তরগুলো থাকলে ক্ষতি কতদূর যায়, ধাপে ধাপে দেখুন:

  1. Networkpg_hba, TLSকে পৌঁছাতে পারে
  2. অ্যাপের কোড? placeholderগঠন স্থির
  3. Least privilegereport_botকী করা যায়
  4. View, RLSPOLICYকোন সারি-কলাম
  5. Encryptionargon2, keyপেলেও অর্থহীন
  6. Audit, backuppgaudit, PITRজানা আর ফেরা
স্তরটি কী করেপরের স্তর কেন লাগে

            
ঝুঁকির অবস্থা
বাইরে থেকে সরাসরি
ডেটা বদল/মোছা
স্পর্শকাতর ডেটা পড়া
  1. Network. ডেটাবেস ইন্টারনেট থেকে দেখাই যায় না; শুধু অ্যাপ সার্ভার, TLS আর SCRAM দিয়ে। বাইরের স্ক্যানার এখানেই থামে। কিন্তু অ্যাপ নিজে তো ঢুকতে পারে — তাই অ্যাপের ভেতর দিয়ে আসা ঝুঁকি থেকে যায়।
  2. অ্যাপের কোড। নিয়ম: placeholder আর allow-list, review-এ আর স্বয়ংক্রিয় টুলে জোড়া-দেওয়া SQL খোঁজা। তবু হাজার পাতার একটি পুরোনো পাতায় ভুল থেকে গেছে। একটি স্তর নিখুঁত — এই অনুমানের ওপর নিরাপত্তা দাঁড়ায় না।
  3. Least privilege. ভুল পাতাটি চলে শুধু-পড়ার report_bot হিসেবে, যে কয়েকটি view আর টেবিলই পড়তে পারে। ডেটাবেস নিজেই লেখা, মোছা আর phone-ওয়ালা মূল টেবিল পড়া আটকায়। ক্ষতির সীমা এখানেই নির্ধারিত হয়।
  4. View আর RLS. যা পড়া যায়, সেখানেও policy শুধু বর্তমান দোকানের সারি দেয়, view শুধু দরকারি কলাম। অন্য tenant-এর ডেটা নাগালের বাইরে।
  5. Encryption আর hashing. সবচেয়ে স্পর্শকাতর কলাম encrypt করা, key অন্য জায়গায়; password-এর বদলে argon2 hash. কোনোভাবে কিছু বাইরে গেলেও কাজে লাগানো কঠিন।
  6. Audit আর backup. অস্বাভাবিক ত্রুটির ঢেউ সতর্কবার্তা তোলে, log বলে দেয় কোন পাতা, কখন। কিছু বদলে থাকলে backup থেকে ঠিক আগের মুহূর্তে ফেরা যায়।

৯ · Privacy, আইন আর test-এর জন্য masked ডেটা

নিরাপত্তা জিজ্ঞেস করে “কে পড়তে পারে”; privacy জিজ্ঞেস করে আরও আগের প্রশ্ন — “এই ডেটা আদৌ রাখা উচিত কি?” কোনো মানুষকে চেনা যায় এমন তথ্যকে বলে personal data — নাম, ফোন, NID, ঠিকানা, এমনকি অর্ডারের ইতিহাস। ইউরোপীয় ইউনিয়নের GDPR (২০১৬-এ গৃহীত, ২০১৮-এর মে থেকে প্রযোজ্য) এ বিষয়ে সবচেয়ে প্রভাবশালী আইন; তার মূলনীতিগুলো ডেটাবেস ডিজাইনে সরাসরি প্রভাব ফেলে:

  • Data minimisation: শুধু যা দরকার, তাই সংগ্রহ। ডেলিভারির জন্য জন্মতারিখ লাগে না — কলামটিই রাখবেন না। যে ডেটা নেই, তা ফাঁসও হয় না।
  • Purpose limitation: যে কাজের জন্য নেওয়া, সেই কাজেই ব্যবহার।
  • Storage limitation (retention): নির্দিষ্ট সময় পরে মুছে ফেলা বা নাম-পরিচয়হীন করা। প্রতিটি টেবিলের জন্য একটি লেখা নিয়ম আর একটি নিয়মিত চলা কাজ।
  • Integrity and confidentiality, accountability: উপযুক্ত নিরাপত্তা, আর প্রমাণ করতে পারা যে নিয়ম মানা হচ্ছে — এখানে audit log কাজে লাগে।

বাংলাদেশে ব্যক্তিগত তথ্য সুরক্ষা নিয়ে আলাদা আইনের খসড়া কয়েক বছর ধরে আলোচনায় ছিল, আর এর অবস্থা সাম্প্রতিক সময়ে বদলেছে বলে জানা যায়। কোনটি কার্যকর আর কোন ধারা আপনার প্রতিষ্ঠানে প্রযোজ্য — সরকারের অফিসিয়াল সূত্র থেকে সর্বশেষ অবস্থা অবশ্যই যাচাই করে নিন; ব্যাংক, টেলিকম বা স্বাস্থ্যের মতো খাতে নিয়ন্ত্রক সংস্থার আলাদা নির্দেশনাও থাকতে পারে।

Dev আর test-এর জন্য masked কপি

একটি খুব সাধারণ ফাঁস হয় নিরাপত্তার মূল দরজা দিয়ে নয়, পাশের দরজা দিয়ে: production-এর পুরো কপি কোনো ডেভেলপারের ল্যাপটপে, test সার্ভারে, বা বাইরের কোনো প্রতিষ্ঠানের হাতে। নিয়ম: test-এর ডেটা হবে masked — গঠন আর বৈশিষ্ট্য (শহর, তারিখের ধরন, NULL কোথায়) একই, কিন্তু মানুষ চেনা যায় না:

CREATE TABLE customers_test AS
SELECT customer_id,
       'Customer ' || customer_id AS name,
       city,
       CASE WHEN phone IS NULL THEN NULL
            ELSE substr(phone, 1, 3) || '*****' || substr(phone, -3) END AS phone,
       substr(joined_on, 1, 7) || '-01' AS joined_on
FROM customers;
SELECT * FROM customers_test;
-- আসল ফোন নম্বর কি কোনোটি চুইয়ে এসেছে?
SELECT COUNT(*) AS leaked FROM customers_test t JOIN customers c ON c.phone = t.phone;

আটটি সারি, যেমন 1 | Customer 1 | Dhaka | 017*****001 | 2025-01-01; Tanvir-এর সারি 5 | Customer 5 | Khulna | NULL | 2025-05-01, Imran-এর শহর NULL-ই আছে — test-এ NULL-এর আচরণ যাচাই করা যাবে। তারিখ শুধু মাসে নামানো হয়েছে। শেষ কোয়েরি leaked = 0 — কোনো আসল নম্বর কপিতে নেই। কিন্তু সতর্ক থাকুন: customer_id রাখা হয়েছে, যাতে orders-এর সাথে join চলে। আসল টেবিল যার হাতে আছে, সে id মিলিয়ে মানুষটিকে চিনতে পারবে। এটি তাই pseudonymisation, সম্পূর্ণ anonymisation নয় — GDPR-এর চোখে এটি এখনো personal data. বাইরে দিতে হলে id-ও নতুন করে দিন, আর ছোট দলে (যেমন একটি শহরে একজনই) মানুষ চেনা যায় কি না ভাবুন।

শেষ একটি বাস্তব প্রশ্ন: গ্রাহক তাঁর তথ্য মুছতে বললেন, কিন্তু হিসাবের আইনে অর্ডারগুলো কয়েক বছর রাখতে হবে। উপায় হলো সারি না মুছে ব্যক্তিগত অংশটুকু মুছে দেওয়া:

UPDATE customers
SET name = 'Deleted customer #' || customer_id, phone = NULL, city = NULL
WHERE customer_id = 4;
SELECT c.customer_id, c.name, c.phone, COUNT(o.order_id) AS orders
FROM customers c LEFT JOIN orders o ON o.customer_id = c.customer_id
WHERE c.customer_id = 4
GROUP BY c.customer_id;

UPDATE-এ “১টি সারি বদলেছে”, তারপর 4 | Deleted customer #4 | NULL | 1 — Nusrat-এর নাম, ফোন আর শহর নেই, কিন্তু তাঁর অর্ডার (1005) হিসাবে টিকে আছে, foreign key-ও ভাঙেনি। মনে রাখবেন, মুছে ফেলা তথ্য পুরোনো backup আর log-এ থেকে যায় — retention নীতিতে সেগুলোর মেয়াদও লেখা থাকা চাই।

১০ · AI-এর যুগে ডেটাবেস নিরাপত্তা

AI কোড-সহকারী মুহূর্তে ডেটাবেসের কোড লিখে দেয়, আর সেটি সাধারণত চলে। কিন্তু নিরাপত্তার ভুল চলার সময় দেখা যায় না — দেখা যায় দুর্ঘটনার পরে। AI-এর লেখা কোডে বারবার যা দেখা যায়:

AI-এর ভুলফলকীভাবে ধরবেন
f-string বা + দিয়ে কোয়েরি, বিশেষ করে সার্চ আর ফিল্টারেinjection-এর ফাঁককোডে execute(f" আর " + খুঁজুন; '-ওয়ালা নাম দিয়ে test
ORDER BY-তে placeholder, বা কাঁচা কলামের নামনীরবে না-সাজানো, বা ফাঁকallow-list চান
GRANT ALL, superuser দিয়ে অ্যাপের সংযোগএকটি বাগে পুরো ডেটাবেসঅ্যাপের role-এর অধিকার তালিকা করে দেখুন
কোডে password, sslmode=disable, সার্টিফিকেট যাচাই বন্ধগোপন তথ্য Git-এ, আড়ি পাতার সুযোগsecrets manager, verify-full
password-এর জন্য MD5/SHA-256, salt ছাড়াফাঁস হলে দ্রুত অনুমানযোগ্যargon2/bcrypt library চান
RLS policy, কিন্তু FORCE নেই আর অ্যাপ টেবিলের মালিকpolicy কার্যত বন্ধঅ্যাপের role দিয়ে অন্য tenant-এর সারি খুঁজে দেখুন
ব্যবহারকারীকে কাঁচা ডেটাবেস ত্রুটি দেখানোschema ফাঁসভুল ইনপুট দিয়ে পর্দার বার্তা দেখুন

AI-কে শুধু “লগইন কোড দাও” না বলে, নিরাপত্তার শর্তগুলো প্রম্পটেই লিখে দিন:

আমার স্ট্যাক: Python 3.12, PostgreSQL 16, psycopg 3. টেবিল: customers(customer_id int PK, name text, city text, phone text UNIQUE), orders(order_id int PK, customer_id int FK, order_date date, status text). একটি ফাংশন দাও: গ্রাহকের নাম দিয়ে খোঁজ, আর ঐচ্ছিক sort (name, city বা newest). শর্ত: ১. সব মান parameter হিসেবে যাবে; কোনো f-string বা স্ট্রিং জোড়া নয়। ২. sort-এর জন্য allow-list; তালিকার বাইরে হলে ValueError. ৩. অ্যাপ চলবে app_web role দিয়ে — এই ফাংশনের জন্য ন্যূনতম GRANT স্টেটমেন্টগুলো আলাদা করে দাও। ৪. ত্রুটি হলে ব্যবহারকারী শুধু সাধারণ বার্তা পাবে, বিস্তারিত যাবে log-এ। ৫. connection string environment variable থেকে, sslmode=verify-full. ৬. শেষে ৫টি test দাও: সাধারণ নাম, ' চিহ্নওয়ালা নাম, খালি নাম, অচেনা sort key, আর এমন নাম যা নেই। কোনো কলাম বা টেবিলের নাম অনুমান করবে না — না জানলে জিজ্ঞেস করো।

AI-এর দেওয়া ডেটাবেস কোড চালু করার আগে — চেকলিস্ট

পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:

  • SQL injection কী, আর প্রধান প্রতিরোধ? — ইনপুট SQL-এর লেখার অংশ হয়ে কোয়েরির গঠন বদলানো; প্রতিরোধ parameterised query / prepared statement, সাথে least privilege.
  • Authentication আর authorization-এর পার্থক্য? — প্রথমটি পরিচয় প্রমাণ (“আপনি কে”), দ্বিতীয়টি অধিকার (“আপনি কী করতে পারেন”, GRANT/REVOKE).
  • Principle of least privilege কী? — প্রতিটি অ্যাকাউন্ট কাজের জন্য ন্যূনতম অধিকার পায়; অ্যাপ, রিপোর্ট আর অ্যাডমিনের আলাদা role.
  • Password encrypt করে রাখা কেন ভুল? — encrypt ফেরানো যায়; রাখতে হয় salt-সহ ধীর একমুখী hash (argon2, bcrypt).
  • CIA triad কী? — confidentiality, integrity, availability — ডেটা নিরাপত্তার তিন লক্ষ্য।

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।

প্র ০১SQL injection-এর প্রতিকার বহু বছর ধরে জানা, তবু এটি বড় বড় ঘটনার কারণ হয়েছে। এত সহজ সমাধানের সমস্যা টিকে আছে কেন?

সমস্যাটির বয়স প্রায় তিন দশক — ১৯৯৮-এর Phrack-এর লেখা থেকে ধরলে। প্রতিকারও প্রায় ততদিনের: JDBC-র PreparedStatement ১৯৯০-এর দশকেই ছিল। তবু প্রকাশিত তদন্ত অনুযায়ী ২০০৮ সালের Heartland Payment Systems-এর বিশাল কার্ড-তথ্য চুরির শুরু হয়েছিল একটি SQL injection দিয়ে, আর যুক্তরাজ্যের TalkTalk-এর ২০১৫-এর ঘটনায় তথ্য কমিশনারের অফিস প্রতিষ্ঠানটিকে জরিমানা করে, যেখানে আক্রমণের পথ ছিল পুরোনো একটি ওয়েব পাতার SQL injection. এমন ঘটনা কেন বারবার?

প্রথম কারণ মানুষের অভ্যাস: স্ট্রিং জোড়া দেওয়া সবচেয়ে সহজ, সবচেয়ে চোখে-পড়া পথ। প্রোগ্রামিং শেখার প্রথম দিনেই মানুষ "Hello, " + name লেখেন, আর SQL-কেও আরেকটি স্ট্রিং মনে হয়।

দ্বিতীয় কারণ পুরোনো কোড। TalkTalk-এর মতো ঘটনায় দুর্বল পাতাটি প্রায়ই নতুন নয় — কোনো অধিগ্রহণ করা প্রতিষ্ঠানের, কোনো ভুলে-যাওয়া অ্যাডমিন পাতা, যেটি কেউ আর দেখে না। একটি প্রতিষ্ঠানের নতুন কোড নিখুঁত হলেও পুরোনো হাজার পাতার একটিই যথেষ্ট।

তৃতীয় কারণ, test-এ বাগটি ধরা পড়ে না। অংশ ২-এর ডায়াগ্রামে দেখলেন — সাধারণ নামে জোড়া-দেওয়া কোডও ঠিক কাজ করে। কার্যকারিতার test পাস করে, শুধু নিরাপত্তার test বা code review ধরতে পারে।

চতুর্থ কারণ, placeholder যেখানে চলে না — ORDER BY, টেবিলের নাম, IN-তালিকার দৈর্ঘ্য — সেখানে মানুষ আবার জোড়ায় ফিরে যান, allow-list না লিখে।

সমাধানগুলো তাই শুধু প্রযুক্তিগত নয়, প্রক্রিয়াগত: ORM বা query builder-কে ডিফল্ট করা, static analysis টুলে জোড়া-দেওয়া SQL-কে ত্রুটি গণ্য করা, পুরোনো পাতাগুলোর তালিকা রেখে সরিয়ে ফেলা, আর least privilege দিয়ে নিশ্চিত করা যে একটি ভুল পাতা পুরো ডেটাবেস নয়।

মূল উপলব্ধি: জানা সমাধান যথেষ্ট নয়; সমাধানটিকে সবচেয়ে সহজ পথ বানাতে হয়, আর ধরে নিতে হয় কোথাও একটি ভুল থাকবেই।

প্র ০২একজন সহকর্মী বলছেন: “আমরা সব ইনপুট validate করি আর উদ্ধৃতিচিহ্ন escape করি, তাই prepared statement লাগবে না।” তাঁকে কী বলবেন?

প্রথমে মেনে নিন, validation ভালো জিনিস। ফোন নম্বর ১১ অঙ্কের কি না, পরিমাণ ধনাত্মক কি না — এগুলো আগেই যাচাই করলে ভুল ডেটা ঢোকে না, আর ব্যবহারকারী ভালো বার্তা পান। কিন্তু validation-এর লক্ষ্য “ডেটা ব্যবসার নিয়মে বৈধ কি না”, আর injection-এর প্রশ্ন “ডেটা কোয়েরির গঠন বদলাতে পারে কি না” — দুটি আলাদা প্রশ্ন। নাম, ঠিকানা, পণ্যের বিবরণ, গ্রাহকের মন্তব্য — এসব মুক্ত লেখায় ' সম্পূর্ণ বৈধ। O'Brien নামটি বাতিল করা যায় না।

Escape নিয়ে সমস্যা আরও সূক্ষ্ম। প্রথমত, প্রসঙ্গ অনেক: string literal-এর ভেতরে এক নিয়ম, সংখ্যার জায়গায় উদ্ধৃতিচিহ্নই নেই (তাই escape কিছুই করে না), identifier-এর জন্য আরেক নিয়ম, LIKE-এর % আর _-এর আরেক। দ্বিতীয়ত, DBMS-ভেদে নিয়ম আলাদা — MySQL-এ ডিফল্টে backslash-ও escape চিহ্ন, standard SQL-এ নয়। তৃতীয়ত, character encoding: সংযোগের character set আর escape-ফাংশনের ধারণা না মিললে escape ব্যর্থ হওয়ার পরিচিত ঐতিহাসিক উদাহরণ আছে — বহু-বাইটের কিছু encoding-এ। চতুর্থত, মানুষের ভুল: হাজার জায়গায় ঠিক মনে রাখতে হয়, একটি জায়গায় ভুললেই ফাঁক।

Prepared statement এই সব প্রশ্ন থেকে মুক্ত, কারণ এটি নিয়ম মেনে চলে না, নিয়মের দরকারই সরিয়ে দেয় — মান কখনো SQL হিসেবে parse-ই হয় না।

সহকর্মীকে তাই বলবেন: validation রাখুন — defence in depth-এর একটি স্তর হিসেবে, ভালো ব্যবহারকারী-অভিজ্ঞতার জন্য। কিন্তু নিরাপত্তার ভার তার ওপর দেবেন না। escape-এর হাতে লেখা কোড মুছে placeholder-এ যান; আর যেখানে placeholder চলে না, সেখানে allow-list.

মূল উপলব্ধি: validation ঠিক করে ডেটা “গ্রহণযোগ্য” কি না; parameterisation ঠিক করে ডেটা “কোড হতে পারে” কি না। দ্বিতীয়টিই injection-এর প্রশ্ন।

প্র ০৩Password-এর hash রাখতে SHA-256 কেন যথেষ্ট নয়? salt আর “ইচ্ছা করে ধীর” অ্যালগরিদমের পেছনের যুক্তি কী?

শুরু করুন আক্রমণকারীর দৃষ্টি থেকে, যাঁর হাতে একটি ফাঁস হওয়া hash-এর টেবিল। তিনি hash উল্টাতে পারেন না, কিন্তু অনুমান করতে পারেন: জনপ্রিয় password-এর তালিকা, অভিধানের শব্দ, নাম আর জন্মসাল — প্রতিটির hash বানিয়ে মেলানো। SHA-256 ডিজাইন করা হয়েছে দ্রুত হওয়ার জন্য; আধুনিক GPU সেকেন্ডে কোটি কোটি SHA-256 হিসাব করতে পারে। তাই বেশিরভাগ মানুষের password, যা খুব এলোমেলো নয়, অল্প সময়েই বেরিয়ে আসে।

Salt না থাকলে আরও খারাপ। একই password-এর hash সবার জন্য এক, তাই একবার হিসাব করে রাখা বিশাল তালিকা (rainbow table) দিয়ে লাখো অ্যাকাউন্ট একসাথে ধরা যায়, আর একই hash দেখেই বোঝা যায় কারা একই password ব্যবহার করেন। ২০১২ সালে LinkedIn-এর ফাঁস হওয়া password hash ছিল salt ছাড়া SHA-1 — যা নিরাপত্তা জগতে সতর্কতার উদাহরণ হয়ে আছে। প্রতি ব্যবহারকারী আলাদা এলোমেলো salt দিলে প্রতিটি অ্যাকাউন্টের জন্য আলাদা করে অনুমান চালাতে হয়।

তারপর ধীরতা। bcrypt (Provos আর Mazières, ১৯৯৯) একটি খরচের মাপ দেয়, যা বাড়ালে প্রতিটি hash-এ সময় বাড়ে। scrypt (২০০৯) আর Argon2 (২০১৫ সালের Password Hashing Competition-এর বিজয়ী, পরে RFC 9106) মেমরিও খায় — ফলে GPU বা বিশেষ চিপে হাজারটি একসাথে চালানো ব্যয়বহুল হয়। একজন বৈধ ব্যবহারকারীর লগইনে ধরুন এক সেকেন্ডের ক্ষুদ্র একটি ভগ্নাংশ লাগে — কেউ টেরও পান না; কিন্তু কোটি অনুমানের জন্য সেটি গুণ হয়ে বিশাল খরচ।

দুটি ব্যবহারিক কথা। নিজে অ্যালগরিদম বানাবেন না বা SHA-256-কে হাজারবার চালিয়ে “নিজস্ব” পদ্ধতি বানাবেন না — পরীক্ষিত library ব্যবহার করুন। আর hash-এর লেখায় মাপগুলো জমা থাকে, তাই হার্ডওয়্যার যত দ্রুত হয়, মাপ বাড়িয়ে লগইনের সময় নতুন hash বসানো যায়।

মূল উপলব্ধি: password hashing-এ গতি শত্রু। salt একসাথে বহু অ্যাকাউন্ট ধরা আটকায়, ধীরতা প্রতিটি অনুমানকে ব্যয়বহুল করে।

প্র ০৪বহু দোকানের একটি SaaS সফটওয়্যারে tenant আলাদা রাখতে — অ্যাপের প্রতিটি কোয়েরিতে WHERE shop_id, PostgreSQL-এর RLS, নাকি প্রতিটি দোকানের আলাদা ডেটাবেস? লাভ-ক্ষতি কী?

তিনটি পথ, তিন রকম দাম। প্রথম পথ — অ্যাপের কোডে প্রতিটি কোয়েরিতে WHERE shop_id = ?. সহজ, যেকোনো DBMS-এ চলে, পারফরম্যান্স অনুমানযোগ্য। দুর্বলতা মানুষ: শত শত কোয়েরির একটিতে, বা নতুন ডেভেলপারের লেখা একটি রিপোর্টে, শর্তটি বাদ পড়লেই এক দোকান অন্য দোকানের বিক্রি দেখবে। ORM-এর “global scope” বা middleware এই ঝুঁকি কমায়, কিন্তু কাঁচা SQL-এ পাহারা থাকে না।

দ্বিতীয় পথ — RLS. নিয়মটি টেবিলে বসানো, তাই যে কোয়েরিই আসুক, DBMS নিজে ছাঁকনি যোগ করে। ভুলে-যাওয়া WHERE আর বিপদ নয়। খরচগুলো: প্রতিটি transaction-এ SET LOCAL ঠিকভাবে বসাতে হয় — connection pool-এ এটি ভুল হলে এক অনুরোধের মান অন্যটিতে চলে যেতে পারে; policy-র শর্ত প্রতিটি কোয়েরিতে যোগ হয়, তাই shop_id-তে index জরুরি, আর জটিল policy optimizer-কে কঠিন করে। অ্যাপকে মালিক বা BYPASSRLS ছাড়া একটি role দিতে হয়, নইলে নিয়ম কাজই করে না। আর ডিবাগ করা কঠিন — “আমার কোয়েরিতে সারি আসছে না কেন?” প্রশ্নের উত্তর থাকে কোয়েরির বাইরে।

তৃতীয় পথ — দোকান প্রতি আলাদা ডেটাবেস বা schema. বিচ্ছিন্নতা সবচেয়ে শক্ত: এক দোকানের ডেটা অন্য ডেটাবেসে প্রশ্নই আসে না, এক দোকানের backup বা মোছা আলাদা করা যায় — privacy-র অনুরোধে সুবিধা। কিন্তু হাজার দোকানে হাজার ডেটাবেস মানে হাজারবার migration, সংযোগের সংখ্যা বাড়া, আর সব দোকানের মিলিত রিপোর্ট কঠিন।

অনেক প্রতিষ্ঠান মিশ্র পথে যায়: ছোট গ্রাহকেরা ভাগ-করা টেবিলে, অ্যাপের scope আর RLS দুটিই একসাথে (একটি অন্যটির পাহারাদার); বড় বা নিয়ন্ত্রিত খাতের গ্রাহক আলাদা ডেটাবেসে। যেটিই হোক, একটি স্বয়ংক্রিয় test রাখুন: দোকান A হিসেবে লগইন করে দোকান B-এর একটি পরিচিত সারি খোঁজা — ফল সবসময় শূন্য হতে হবে।

মূল উপলব্ধি: বিচ্ছিন্নতার নিয়ম যত কাছে ডেটার পাশে বসে, ভুলে যাওয়ার সুযোগ তত কমে — কিন্তু পরিচালনার জটিলতা তত বাড়ে। দুটি স্তর একসাথে রাখাই সবচেয়ে নিরাপদ।

প্র ০৫“আমাদের ডেটাবেস encrypted at rest, তাই ডেটা নিরাপদ” — এই কথায় কী সত্য আর কী ভুল ধারণা আছে?

সত্য অংশটি গুরুত্বপূর্ণ। ডিস্ক বা ফাইল-স্তরের encryption, বা SQL Server-Oracle-MySQL-এর TDE থাকলে, কেউ ডেটা সেন্টার থেকে হার্ডড্রাইভ নিয়ে গেলে, পুরোনো সার্ভার বিক্রির সময় ডিস্ক মুছতে ভুলে গেলে, বা backup ফাইল ভুল জায়গায় গেলে, key ছাড়া সেটি অর্থহীন।

ভুল ধারণাটি হলো এটিকে সর্বাঙ্গীণ সুরক্ষা ভাবা। ডেটাবেস চলার সময় নিজেই ডেটা খুলে রাখে — নইলে কোয়েরি চলবে কীভাবে? তাই যে কেউ বৈধ পথে, অর্থাৎ SQL দিয়ে, ডেটাবেসে পৌঁছায় — অ্যাপের একটি injection বাগ দিয়ে হোক, চুরি যাওয়া password দিয়ে হোক, বা অতিরিক্ত অধিকারওয়ালা কর্মী হিসেবে হোক — তার কাছে at-rest encryption অদৃশ্য: সে সাধারণ লেখাই দেখে। অংশ ৮-এর স্তরের ছবিতে এটি একটিমাত্র স্তর, আর বাস্তবের বেশিরভাগ ফাঁস ঘটে অন্য স্তরগুলোর ভুলে।

দ্বিতীয় প্রশ্ন key. key যদি একই সার্ভারে, একই ডিস্কে, বা একই backup-এ থাকে, তাহলে তালা আর চাবি একই থলিতে। ঠিক নকশায় key থাকে আলাদা key management সেবায় বা হার্ডওয়্যার মডিউলে, কারা ব্যবহার করতে পারে তার আলাদা অধিকার আর log-সহ, আর key বদলানোর (rotation) পরিকল্পনা থাকে। key হারালে ডেটাও হারায় — availability-র ঝুঁকি, তাই key-র নিজেরও নিরাপদ backup চাই।

তৃতীয়ত, কলাম-স্তরের encryption কিছু ফাঁক বন্ধ করে — DBA বা SQL-পড়ুয়া key ছাড়া NID দেখেন না — কিন্তু দাম দেয় খোঁজা, সাজানো, index আর join-এ। তাই এটি অল্প কয়েকটি কলামের জন্য। সবশেষে, encryption ডেটা বদল (integrity) বা মুছে ফেলা (availability) ঠেকায় না — সেখানে privilege, audit আর backup.

মূল উপলব্ধি: at-rest encryption চুরি যাওয়া ডিস্ক থেকে বাঁচায়, চুরি যাওয়া অধিকার থেকে নয়। প্রতিটি সুরক্ষার আগে জিজ্ঞেস করুন: এটি কোন হুমকির উত্তর?

অনুশীলন

একটি কোয়েরি লেখা, একটি হাতে ফল বের করা, একটি ডিজাইন, একটি কোড পর্যালোচনা।

  1. কোয়েরি লিখুন (data minimisation): মার্কেটিং দল জানতে চায় কোন শহর থেকে কত অর্ডার আর কত টাকার বিক্রি — কোনো নাম বা ফোন নম্বর ছাড়া। db-shop-এ একটি view city_sales লিখুন: শহর (NULL হলে '(unknown)'), বাতিল-না-হওয়া অর্ডারের সংখ্যা আর মোট বিক্রি (quantity × unit_price), বিক্রি অনুযায়ী বড় থেকে ছোট। (ওপরের যেকোনো db-shop এডিটরে চালিয়ে দেখতে পারেন।)
    CREATE VIEW city_sales AS
    SELECT COALESCE(c.city, '(unknown)') AS city,
           COUNT(DISTINCT o.order_id)    AS orders,
           SUM(i.quantity * i.unit_price) AS revenue
    FROM orders o
    JOIN customers c   ON c.customer_id = o.customer_id
    JOIN order_items i ON i.order_id = o.order_id
    WHERE o.status <> 'cancelled'
    GROUP BY COALESCE(c.city, '(unknown)');
    
    SELECT * FROM city_sales ORDER BY revenue DESC;

    ফল পাঁচটি সারি: Dhaka — ৪টি অর্ডার, 37730; Rajshahi — ১টি, 5050; Chattogram — ২টি, 5000; Sylhet — ১টি, 3500; (unknown) — ১টি, 1040 (Imran-এর শহর NULL). Sabbir-এর একমাত্র অর্ডার (1004) বাতিল, তাই বাদ। COUNT(DISTINCT …) জরুরি, কারণ join-এর পর একটি অর্ডার তার লাইনের সংখ্যার সমান বার আসে। এবার মার্কেটিং-এর role-কে শুধু এই view-এ SELECT দিলে, ব্যক্তিগত তথ্য তাদের নাগালেই আসে না।

  2. ফল আগে বলুন (trace): db-shop-এর customers-এ (ঢাকা: Rahim 2025-01-15, Sabbir 2025-03-21, Mitu 2025-08-12; সিলেট: Farhana 2025-06-18) চালানো হলো (ক) SELECT name FROM customers WHERE city = 'Dhaka' OR city = 'Sylhet' AND joined_on >= '2025-06-01' আর (খ) একই কোয়েরি, কিন্তু (city = 'Dhaka' OR city = 'Sylhet') AND joined_on >= '2025-06-01'. প্রতিটিতে কয়টি সারি, কারা? এ থেকে স্ট্রিং জোড়ার বিপদ নিয়ে কী শিখবেন?

    (ক) ৪টি সারি — Rahim Uddin, Sabbir Hasan, Farhana Akter, Mitu Rani (টেবিলের ক্রমে)। AND আগে বাঁধে, তাই শর্তটি আসলে city = 'Dhaka' OR (city = 'Sylhet' AND joined_on >= '2025-06-01'): ঢাকার তিনজনই তারিখ ছাড়াই আসেন, সিলেটের Farhana তারিখের শর্তে পাস।

    (খ) ২টি সারি — Farhana Akter আর Mitu Rani. এবার তারিখের শর্ত দুই শহরেই প্রযোজ্য; Rahim আর Sabbir জুনের আগে যোগ দিয়েছেন।

    শিক্ষা: একটি শর্তের অর্থ নির্ভর করে লেখার গঠনের ওপর, আর গঠন কয়েকটি অক্ষরে বদলে যায়। স্ট্রিং জোড়া দিলে ইনপুটের অক্ষরগুলোও এই গঠনে অংশ নেয় — অংশ ২-এর মূল বিপদ। নিজের লেখা কোয়েরিতেও AND আর OR একসাথে থাকলে সবসময় বন্ধনী দিন।

  3. ডিজাইন করুন: বিশ্ববিদ্যালয়ের ফলাফলের সিস্টেম (db-uni-এর মতো: students, courses, enrollments-এ grade). ব্যবহারকারী চার রকম — শিক্ষার্থী (নিজের গ্রেড দেখবেন), শিক্ষক (নিজের কোর্সের গ্রেড দেবেন), রেজিস্ট্রার অফিস (সব দেখবেন, ফল প্রকাশ করবেন), আর একটি ওয়েব অ্যাপ. কোন role, কোন অধিকার, কোথায় view বা RLS, আর কী audit করবেন?
    • Role: uni_student, uni_teacher, uni_registrar (সবই NOLOGIN দল); ওয়েব অ্যাপের লগইন app_results; migration-এর জন্য আলাদা uni_owner, যা টেবিলের মালিক আর অন্য কেউ ব্যবহার করে না।
    • শিক্ষার্থী: enrollments-এ SELECT, আর RLS policy USING (student_id = current_setting('app.student_id')::int); ফোন বা ঠিকানার মতো অন্যের তথ্য থাকা টেবিলে কোনো অধিকার নয়।
    • শিক্ষক: শুধু UPDATE (grade) কলাম-অধিকার, আর policy: যে কোর্স তিনি পড়ান তার সারি; ফল প্রকাশের পর বদল আটকাতে trigger বা policy-তে status শর্ত।
    • রেজিস্ট্রার: সব পড়া, প্রকাশের status বদলানো; কোনো DDL নয়।
    • Audit: প্রতিটি grade বদল (পুরোনো ও নতুন মান, কে, কখন) — trigger বা pgaudit-এর write log; role ও অধিকারের প্রতিটি বদল।

    ওয়েব অ্যাপ লগইন করা মানুষের পরিচয় অনুযায়ী প্রতিটি transaction-এ SET LOCAL ROLE আর session-এর id বসাবে; আর ব্যবহারকারীর password নিজের টেবিলে argon2 hash হিসেবে।

  4. কোড পর্যালোচনা করুন: নিচের Node.js কোডে অন্তত চারটি নিরাপত্তার সমস্যা খুঁজুন আর ঠিক করুন: pool = new Pool({ user: 'postgres', password: 'admin123', ssl: false }); একটি route-এ pool.query("SELECT * FROM customers WHERE city = '" + req.query.city + "' ORDER BY " + req.query.sort); ত্রুটি হলে res.status(500).send(err.message).
    1. superuser দিয়ে সংযোগ — অ্যাপের জন্য least-privilege role (যেমন app_web).
    2. কোডে password, তাও দুর্বল — environment variable বা secrets manager, লম্বা এলোমেলো password, আর ফাঁস হয়ে থাকলে এখনই rotation.
    3. TLS বন্ধ — ssl চালু, সার্টিফিকেট যাচাইসহ।
    4. city জোড়া দেওয়া — $1 placeholder.
    5. sort সরাসরি SQL-এ — allow-list.
    6. SELECT * — phone-সহ সব কলাম বাইরে যায়; শুধু দরকারি কলাম।
    7. কাঁচা ত্রুটি-বার্তা — ব্যবহারকারীকে সাধারণ বার্তা, বিস্তারিত সার্ভারের log-এ।
    const SORTS = { name: 'name', city: 'city', newest: 'customer_id DESC' };
    const order = SORTS[req.query.sort] ?? 'name';
    try {
      const { rows } = await pool.query(
        `SELECT customer_id, name, city FROM customers WHERE city = $1 ORDER BY ${order}`,
        [req.query.city]);
      res.json(rows);
    } catch (err) {
      log.error({ err, route: 'customers' });            // বিস্তারিত শুধু log-এ
      res.status(500).json({ error: 'Something went wrong' });
    }

    খেয়াল করুন, ${order} template literal-এ জোড়া হলেও নিরাপদ — মানটি সবসময় প্রোগ্রামারের লেখা তালিকা থেকে আসে, ব্যবহারকারীর লেখা থেকে নয়।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমএকটি parameterised query-র ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. প্রোগ্রামার placeholder-সহ SQL-এর লেখা ঠিক করেন
  2. Driver লেখাটি সার্ভারে পাঠায়
  3. সার্ভার লেখাটি parse করে আর plan বানায় — গঠন স্থির হয়
  4. ব্যবহারকারীর মানগুলো আলাদাভাবে bind হয়
  5. কোয়েরি চলে, মানগুলো শুধু ডেটা হিসেবে তুলনা হয়
  6. ফলের সারি অ্যাপে ফেরে

অ্যাপের কোয়েরিতে SQL injection ঠেকানোর প্রধান উপায় কোনটি?

  • সব ইনপুটে নিষিদ্ধ অক্ষরের তালিকা মিলিয়ে বাদ দেওয়া
  • Parameterised query বা prepared statement ব্যবহার করা
  • ডেটাবেস সংযোগে TLS চালু করা
  • ডেটাবেসের ডিস্ক encrypt করা

ওয়েব পাতায় ব্যবহারকারী বেছে নেন কোন কলাম অনুযায়ী তালিকা সাজানো হবে। নিরাপদ পদ্ধতি কোনটি?

  • ORDER BY ? লিখে কলামের নাম parameter হিসেবে পাঠানো
  • কলামের নামটি double quote-এ মুড়ে সরাসরি জোড়া দেওয়া
  • প্রোগ্রামারের লেখা allow-list থেকে চাবি মিলিয়ে কলামের নাম বেছে নেওয়া
  • সব সময় সব কলাম এনে ব্রাউজারে সাজানো, কারণ SQL-এ এটি অসম্ভব

SQL-এ WHERE a OR b AND c শর্তটি কীভাবে মূল্যায়িত হয়?

  • a OR (b AND c)
  • (a OR b) AND c
  • বাঁ থেকে ডানে, যেভাবে লেখা
  • এটি syntax error, বন্ধনী বাধ্যতামূলক

ব্যবহারকারীর password ডেটাবেসে রাখার সঠিক উপায় কোনটি?

  • AES দিয়ে encrypt করে, key সার্ভারে রেখে
  • SHA-256 hash, salt ছাড়া
  • সাধারণ লেখায়, কিন্তু টেবিলে কড়া GRANT দিয়ে
  • প্রতি ব্যবহারকারী আলাদা salt-সহ Argon2id বা bcrypt hash

Ransomware একটি ডেটাবেস সার্ভারের সব ফাইল encrypt করে দিল। CIA triad-এর কোন লক্ষ্যটি সবচেয়ে সরাসরি আঘাত পেল, আর প্রধান প্রতিকার কী?

  • Confidentiality; প্রতিকার কলাম-স্তরের encryption
  • Integrity; প্রতিকার CHECK constraint
  • Availability; প্রতিকার আলাদা জায়গায় রাখা, পরীক্ষিত backup
  • Availability; প্রতিকার TLS চালু করা

test-এর জন্য গ্রাহকের টেবিলের কপিতে নাম বদলে “Customer 1, Customer 2…” করা হলো, ফোন নম্বর মাস্ক করা হলো, কিন্তু customer_id আগের মতোই রাখা হলো। এই কপিটি কী?

  • সম্পূর্ণ anonymised — আর personal data নয়
  • Pseudonymised — এখনো personal data হিসেবে গণ্য
  • Encrypted — key দিয়ে আসল নাম ফেরত পাওয়া যায়
  • অবৈধ — test-এ কখনো গ্রাহকের টেবিলের কপি ব্যবহার করা যায় না

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. CIA triad — গোপনীয়তা, অখণ্ডতা, প্রাপ্যতা; threat model-এ বাইরের লোক, ভেতরের লোক আর আক্রান্ত অ্যাপ — তিনটিই ভাবুন।
  2. SQL injection = ইনপুট কোয়েরির গঠন বদলায়। প্রতিরোধ parameterised query; identifier-এর জন্য allow-list, PostgreSQL-এর dynamic SQL-এ format('%I') আর USING; ডেটাবেস থেকে পড়া মানও বিশ্বাসযোগ্য নয় (second-order).
  3. Least privilege: মানুষ ও অ্যাপ প্রতি আলাদা অ্যাকাউন্ট, SCRAM আর TLS; অধিকার role-এ, role-এ সদস্যপদ; অ্যাপ কখনো মালিক বা superuser নয়; কলাম লুকাতে view, সারি আলাদা রাখতে RLS.
  4. Encryption: পথে TLS, ডিস্কে at-rest, অল্প কয়েকটি স্পর্শকাতর কলামে আলাদা, key ডেটাবেসের বাইরে। password-এর জন্য salt-সহ argon2/bcrypt hash — কখনো সাধারণ লেখা বা encrypt নয়।
  5. জানা আর ফেরা: audit log (কিন্তু log-এ স্পর্শকাতর ডেটা নয়), পরীক্ষিত backup, ন্যূনতম ডেটা, মেয়াদের নিয়ম, আর dev/test-এ masked কপি।

পরের পর্বে: নিরাপত্তার শেষ স্তর ছিল “ফিরতে পারা”। পর্ব ৫২-এ দেখবেন backup, replication আর high availability — কীভাবে ডেটা হারায় না, আর সার্ভার বসে গেলেও সেবা চলে।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান