অধ্যায় 3 · পারফরম্যান্স ও প্রোডাকশন
নিরাপত্তা: রোল, SQL ইনজেকশন, সংবেদনশীল ডেটা আর ব্যাকআপ
- পৃষ্ঠা 11 / 22
- 18 মিনিট পড়া
একটা কোম্পানির সবচেয়ে মূল্যবান ডেটা থাকে ডেটাবেসে: কাস্টমার, পেমেন্ট, আর AI টিমের জন্য — যে ট্রেনিং ডেটা, প্রম্পট আর লগের ওপর মডেল দাঁড়িয়ে থাকে। বেশিরভাগ ডেটা ফাঁসের পেছনে কোনো চতুর আক্রমণ থাকে না, থাকে সাধারণ অবহেলা: এমন একটা অ্যাপ্লিকেশন ইউজার, যে সবকিছু করতে পারে; ব্যবহারকারীর ইনপুট জোড়া দিয়ে বানানো কোয়েরি; Git রিপোজিটরিতে থাকা পাসওয়ার্ড; "একটু বিশ্লেষণের জন্য" ল্যাপটপে কপি করা কাস্টমারদের ইমেইলের CSV; অথবা এমন ব্যাকআপ, যা কেউ কখনো রিস্টোর করে দেখেনি।
এই পাতায় সেই অভ্যাসগুলো আছে, যা এসব বিপদ ঠেকায়, আর প্রতিটা চালিয়ে দেখানো হয়েছে: PostgreSQL আর MySQL-এ রোল আর ন্যূনতম অধিকার, SQL ইনজেকশন ঘটিয়ে দেখানো আর সারানো, অ্যানালিটিক্স বা ML-এ পৌঁছানোর আগে ব্যক্তিগত ডেটার সুরক্ষা, আর এমন ব্যাকআপ যা আপনি সত্যিই রিস্টোর করেছেন।
যা শিখবেন
- ইউজার, রোল,
GRANTআরREVOKE, আর ন্যূনতম অধিকার (least privilege): শুধু-পড়ার একটা অ্যানালিটিক্স রোল আর একটা অ্যাপ্লিকেশন রোল - SQL ইনজেকশন কীভাবে কাজ করে, আর প্যারামিটার (prepared statement) কেন তা থামায়
- ক্রেডেনশিয়াল কোথায় রাখবেন: এনভায়রনমেন্ট ভেরিয়েবল আর সিক্রেট ম্যানেজার
- ব্যক্তিগত ডেটার সুরক্ষা: মাস্কিং আর key-যুক্ত হ্যাশ দিয়ে ছদ্মনাম (pseudonymisation)
- ব্যাকআপ ও রিস্টোর (SQLite backup API,
mysqldump,pg_dump), অডিট লগ আর প্রোডাকশনের একটা চেকলিস্ট
ইউজার, রোল আর ন্যূনতম অধিকার
SQLite-এ কোনো ইউজার নেই: ফাইলটা যে পড়তে পারে, সে সবই পড়তে পারে, তাই একে রক্ষা করতে হয় ফাইলের পারমিশন দিয়ে। সার্ভার ডেটাবেস প্রতিটা কানেকশনকে একজন ইউজার হিসেবে যাচাই করে, আর অধিকার সাধারণত দেওয়া হয় রোলকে (অধিকারের নাম দেওয়া একেকটা বান্ডিল), যার সদস্য হন ইউজাররা। নিয়মটা হলো ন্যূনতম অধিকার: প্রতিটা ইউজার আর প্রোগ্রাম ঠিক ততটুকু অ্যাক্সেস পায়, যতটুকু তার কাজে লাগে। ড্যাশবোর্ড শুধু পড়ে, কখনো মোছে না। ওয়েব অ্যাপ অর্ডার ঢোকায়, কখনো টেবিল ড্রপ করে না। LLM-এর লেখা কোয়েরি চালানো text-to-SQL ফিচার পায় শুধু-পড়ার একটা রোল, এর বেশি কিছু নয়।
PostgreSQL: শুধু-পড়ার অ্যানালিটিক্স রোল
PostgreSQL-এ ইউজার আর রোল একই জিনিস: "ইউজার" মানে LOGIN অনুমতিসহ একটা রোল, আর CREATE USER আসলে CREATE ROLE … LOGIN-এরই আরেক নাম। রোল কোনো একটা ডেটাবেসের নয়, পুরো সার্ভারের, তাই স্ক্রিপ্টটা শুরু হয় আগের কোনো কপি থাকলে তা মুছে দিয়ে:
-- PostgreSQL
DROP ROLE IF EXISTS bi_reader;
DROP ROLE IF EXISTS analytics_ro;
CREATE ROLE analytics_ro NOLOGIN; -- একটা গ্রুপ: এই নামে কেউ লগইন করে না
GRANT USAGE ON SCHEMA public TO analytics_ro;
GRANT SELECT ON orders, order_items, products, categories TO analytics_ro;
CREATE ROLE bi_reader LOGIN PASSWORD 'change-me-123' IN ROLE analytics_ro;
SET ROLE bi_reader; -- পরীক্ষার জন্য ওই ইউজার হয়ে কাজ করুন
SELECT current_user, COUNT(*) AS orders FROM orders;
DELETE FROM orders WHERE order_id = 13;+--------------+--------+
| current_user | orders |
+--------------+--------+
| bi_reader | 14 |
+--------------+--------+
Error: permission denied for table ordersড্যাশবোর্ডের ইউজার অর্ডার গুনতে পারে, কিন্তু মুছতে পারে না। খেয়াল করুন, customers আর payments-এর অনুমতি একেবারেই দেওয়া হয়নি: অ্যানালিস্টরা ব্যক্তিগত ডেটা পান শুধু এমন একটা ভিউয়ের মাধ্যমে, যেখানে তাঁদের অপ্রয়োজনীয় সব বাদ।
-- PostgreSQL
SELECT name, email FROM customers LIMIT 1;Error: permission denied for table customers-- PostgreSQL
RESET ROLE; -- আবার অ্যাডমিনিস্ট্রেটর
CREATE VIEW customers_analytics AS
SELECT customer_id, city, joined_on FROM customers; -- নাম নেই, ইমেইল নেই
GRANT SELECT ON customers_analytics TO analytics_ro;
SET ROLE bi_reader;
SELECT city, COUNT(*) AS customers
FROM customers_analytics
GROUP BY city
ORDER BY customers DESC, city
LIMIT 3;+------------+-----------+
| city | customers |
+------------+-----------+
| Dhaka | 3 |
| Chattogram | 1 |
| Khulna | 1 |
+------------+-----------+একটা অ্যাপ্লিকেশন রোল, আর REVOKE
ওয়েব শপের ব্যাকএন্ডকে প্রোডাক্ট পড়তে আর অর্ডার বানাতে হয়, আর কিছু নয়। REVOKE একটা অধিকার ফিরিয়ে নেয়:
-- PostgreSQL
RESET ROLE;
DROP ROLE IF EXISTS orders_app;
CREATE ROLE orders_app LOGIN PASSWORD 'change-me-456';
GRANT USAGE ON SCHEMA public TO orders_app;
GRANT SELECT ON products, customers TO orders_app;
GRANT SELECT, INSERT, UPDATE ON orders, order_items TO orders_app;
REVOKE UPDATE ON order_items FROM orders_app; -- চেকআউটের পর আইটেম আর বদলানো হয় না
SELECT grantee, table_name, string_agg(privilege_type, ', ' ORDER BY privilege_type) AS privileges
FROM information_schema.role_table_grants
WHERE grantee IN ('analytics_ro', 'orders_app')
GROUP BY grantee, table_name
ORDER BY grantee, table_name;+--------------+---------------------+------------------------+
| grantee | table_name | privileges |
+--------------+---------------------+------------------------+
| analytics_ro | categories | SELECT |
| analytics_ro | customers_analytics | SELECT |
| analytics_ro | order_items | SELECT |
| analytics_ro | orders | SELECT |
| analytics_ro | products | SELECT |
| orders_app | customers | SELECT |
| orders_app | order_items | INSERT, SELECT |
| orders_app | orders | INSERT, SELECT, UPDATE |
| orders_app | products | SELECT |
+--------------+---------------------+------------------------+PostgreSQL-এর আরও তিনটা খুঁটিনাটি। GRANT SELECT ON ALL TABLES IN SCHEMA public শুধু এই মুহূর্তে থাকা টেবিলগুলোতে খাটে; ভবিষ্যতের টেবিলের জন্য লাগে ALTER DEFAULT PRIVILEGES … GRANT SELECT ON TABLES TO analytics_ro। টেবিলের মালিক (owner) টেবিলটা নিয়ে যা খুশি করতে পারে, তাই অ্যাপ্লিকেশন কখনো টেবিলের মালিক রোল হিসেবে কানেক্ট করবে না (মাইগ্রেশন চলে মালিক হিসেবে; অ্যাপ চলে orders_app হিসেবে)। আর PostgreSQL 15 থেকে সাধারণ রোল public স্কিমায় নিজে টেবিল বানাতে পারে না; পুরোনো সার্ভারে নিজেই REVOKE CREATE ON SCHEMA public FROM PUBLIC চালান, নইলে যেকোনো লগইন সেখানে টেবিল যোগ করতে পারবে। (public-এ USAGE এখনো সবাই পায়, তাই ওপরের GRANT USAGE লাইনগুলো আসলে কাজে লাগে অন্য স্কিমার বেলায়; তবে এতে উদ্দেশ্যটা স্পষ্ট থাকে।)
রোল যেহেতু সার্ভারের, কোনো একটা ডেটাবেস মুছে ফেললেও রোল থেকে যায়; তাই কাজ শেষে রোল সরানোও কাজেরই অংশ। রোলের হাতে কোনো অধিকার থাকা অবস্থায় DROP ROLE রাজি হয় না:
-- PostgreSQL
DROP ROLE orders_app;Error: role "orders_app" cannot be dropped because some objects depend on it-- PostgreSQL
DROP OWNED BY orders_app, analytics_ro; -- এই ডেটাবেসে তাদের অধিকার ফিরিয়ে নেয় (আর তাদের মালিকানার সবকিছু মুছে দেয়)
DROP ROLE orders_app;
DROP ROLE bi_reader;
DROP ROLE analytics_ro;DROP OWNED BY একবারে একটা ডেটাবেসেই কাজ করে, আর রোলের মালিকানায় থাকা অবজেক্টও মুছে দেয়; সেগুলো রাখতে চাইলে আগে REASSIGN OWNED BY old_role TO new_role দিয়ে অন্য রোলের নামে সরিয়ে নিন।
MySQL: ইউজার, রোল আর গ্রান্ট
MySQL 8-এ ইউজার আর রোল আলাদা জিনিস। ইউজার হলো 'name'@'host' — host অংশটা ঠিক করে কোথা থেকে কানেক্ট করা যাবে ('%' মানে যেকোনো জায়গা; নির্দিষ্ট নেটওয়ার্ক দেওয়াই ভালো)। শুধু রোল গ্রান্ট করলেই হয় না: রোলের অধিকার খাটে কেবল তখন, যখন সেশনে রোলটা সক্রিয় থাকে। SET ROLE একটা সেশনের জন্য সক্রিয় করে; SET DEFAULT ROLE প্রতিবার লগইনের সময় সক্রিয় করে (বিকল্প হলো সার্ভারের activate_all_roles_on_login সেটিং, যা ডিফল্টে বন্ধ)। নিজের সার্ভারের স্ক্রিপ্টে লিখতেন GRANT SELECT ON shop.* TO analytics_ro; এখানে ডেটাবেসের নাম আসে এনভায়রনমেন্ট থেকে, যেমন আসল ডিপ্লয়মেন্ট স্ক্রিপ্টে আসে:
import os
import pymysql
settings = dict(host=os.environ["MYSQL_HOST"], port=int(os.environ["MYSQL_PORT"]),
database=os.environ["MYSQL_DATABASE"], autocommit=True)
db = os.environ["MYSQL_DATABASE"]
admin = pymysql.connect(user=os.environ["MYSQL_USER"], password=os.environ["MYSQL_PASSWORD"], **settings)
with admin.cursor() as cur:
cur.execute("DROP USER IF EXISTS 'bi_reader'@'%'")
cur.execute("DROP ROLE IF EXISTS 'analytics_ro'")
cur.execute("CREATE ROLE 'analytics_ro'")
cur.execute(f"GRANT SELECT ON `{db}`.* TO 'analytics_ro'")
cur.execute("CREATE USER 'bi_reader'@'%' IDENTIFIED BY 'change-me-789'")
cur.execute("GRANT 'analytics_ro' TO 'bi_reader'@'%'")
cur.execute("SET DEFAULT ROLE 'analytics_ro' TO 'bi_reader'@'%'")
cur.execute("""SELECT GRANTEE, PRIVILEGE_TYPE FROM information_schema.SCHEMA_PRIVILEGES
WHERE GRANTEE = "'analytics_ro'@'%'" """)
print(cur.fetchall())
reader = pymysql.connect(user="bi_reader", password="change-me-789", **settings)
with reader.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM orders")
print("orders visible to bi_reader:", cur.fetchone()[0])
try:
cur.execute("DELETE FROM orders WHERE order_id = 13")
except pymysql.err.OperationalError as error:
print("DELETE refused, MySQL error", error.args[0])
reader.close()
with admin.cursor() as cur: # ইউজার আর রোল পুরো সার্ভারের: কাজ শেষে মুছে ফেলুন
cur.execute("DROP USER 'bi_reader'@'%'")
cur.execute("DROP ROLE 'analytics_ro'")
admin.close()(("'analytics_ro'@'%'", 'SELECT'),)
orders visible to bi_reader: 14
DELETE refused, MySQL error 1142এরর ১১৪২ হলো MySQL-এর "command denied to user"। SET DEFAULT ROLE লাইনটা না থাকলে bi_reader লগইন করত কোনো সক্রিয় রোল ছাড়া, আর ডেটাবেসেই ঢুকতে পারত না। অ্যাক্সেস ফিরিয়ে নিতে: একটা অধিকারের জন্য REVOKE SELECT ON shop.* FROM 'analytics_ro';, অথবা স্ক্রিপ্টের শেষের মতো DROP USER আর DROP ROLE। রোলের মাধ্যমে একজন ইউজার কী কী পারে, তার তালিকা দেয় SHOW GRANTS FOR 'bi_reader'@'%' USING 'analytics_ro';।
SQL ইনজেকশন
SQL ইনজেকশন ঘটে, যখন একটা প্রোগ্রাম ব্যবহারকারীর ইনপুট সরাসরি কোয়েরির টেক্সটে বসিয়ে SQL বানায়। তখন ইনপুট আর ডেটা থাকে না, কোডের অংশ হয়ে যায়। ২০০৩ সালে প্রথম প্রকাশের পর থেকে ওয়েবের ঝুঁকির তালিকা OWASP Top 10-এর প্রতিটা সংস্করণে এটা আছে, আর AI অ্যাপেও এটা একই রকম বাস্তব: "ইউজারের অর্ডারগুলো খোঁজে" এমন একটা চ্যাটবট টুল, বা RAG পাইপলাইনে যাওয়া একটা সার্চ বক্স — ঠিক এ ধরনের কোডই তাড়াহুড়োয় লেখা হয়।
from sqlhelp import con, show
def find_customer_unsafe(email):
sql = f"SELECT customer_id, name, city FROM customers WHERE email = '{email}'" # এমন করবেন না
return con.execute(sql).fetchall()
print(find_customer_unsafe("nadia@example.com"))
print(find_customer_unsafe("nobody' OR '1'='1"))[(1, 'Nadia Rahman', 'Dhaka')]
[(1, 'Nadia Rahman', 'Dhaka'), (2, 'Tanvir Ahmed', 'Chattogram'), (3, 'Farhana Akter', 'Dhaka'), (4, 'Rafiq Islam', 'Sylhet'), (5, 'Sadia Chowdhury', None), (6, 'Imran Hossain', 'Khulna'), (7, 'Mitu Das', 'Dhaka'), (8, 'Karim Uddin', 'Rajshahi')]দ্বিতীয় "ইমেইল"টা উদ্ধৃতিচিহ্ন বন্ধ করে দিয়ে OR '1'='1' জুড়ে দিয়েছে, যা প্রতিটা সারির জন্য সত্য — তাই খোঁজাটা আটজন কাস্টমারকেই ফেরত দিয়েছে। ডেটাবেস আসলে যে কোয়েরি চালিয়েছে:
SELECT customer_id, name, city FROM customers WHERE email = 'nobody' OR '1'='1'আক্রমণকারী আরও এগিয়ে UNION দিয়ে এমন টেবিলও পড়তে পারে, যার নাম মূল কোয়েরিতে ছিলই না (-- মূল কোয়েরির বাকিটা কমেন্ট করে দেয়):
attack = "nobody' UNION SELECT employee_id, name, salary FROM employees --"
for row in find_customer_unsafe(attack)[:3]:
print(row)(1, 'Ayesha Siddiqua', 250000)
(2, 'Hasan Mahmud', 180000)
(3, 'Nusrat Jahan', 120000)কাস্টমার খোঁজার ফাংশন দিয়ে বেরিয়ে এল কর্মীদের বেতন। বিখ্যাত "'; DROP TABLE customers; --" নির্ভর করে ড্রাইভারের ওপর: Python-এর sqlite3 এক execute()-এ দুটো স্টেটমেন্ট চালাতে অস্বীকার করে, কিন্তু অন্য ড্রাইভার আর সেটিং (multi-statements চালু করা MySQL, বা executescript()) চালিয়ে দেবে:
try:
find_customer_unsafe("x'; DROP TABLE customers; --")
except Exception as error:
print(type(error).__name__, "-", error)ProgrammingError - You can only execute one statement at a time.সমাধান: প্যারামিটার
কখনো SQL-এর টেক্সটে মান বসাবেন না। কোয়েরিতে একটা প্লেসহোল্ডার রাখুন আর মানগুলো আলাদা করে দিন, তাহলে মানের ভেতরের কোনো উদ্ধৃতিচিহ্ন বা কিওয়ার্ড কোয়েরি বদলাতে পারবে না। sqlite3 আর psycopg 3-এ এটা সত্যিকারের prepared statement (parameter binding): কোয়েরিটা ফাঁকা জায়গাসহ পার্স হয়, আর মানগুলো আলাদাভাবে, ডেটা হিসেবে পৌঁছায়। PyMySQL আর psycopg2 বরং কোয়েরি পাঠানোর আগে ক্লায়েন্টেই প্রতিটা মান ঠিকমতো কোট আর escape করে নেয়; এটাও সমান নিরাপদ, কারণ কোটিংয়ের কাজটা করে ড্রাইভার, আপনার কোড নয়।
def find_customer(email):
sql = "SELECT customer_id, name, city FROM customers WHERE email = ?"
return con.execute(sql, (email,)).fetchall()
print(find_customer("nadia@example.com"))
print(find_customer("nobody' OR '1'='1"))
print(find_customer("nobody' UNION SELECT employee_id, name, salary FROM employees --"))[(1, 'Nadia Rahman', 'Dhaka')]
[]
[]আক্রমণগুলো এখন এমন একজন কাস্টমার খোঁজে, যার ইমেইল আক্ষরিকভাবেই nobody' OR '1'='1 — এমন কেউ নেই। প্লেসহোল্ডারের ধরন ড্রাইভারভেদে আলাদা: sqlite3-এ ? (বা :name), PyMySQL, psycopg2 আর psycopg 3-এ %s (বা %(name)s)। %s দেখতে Python-এর স্ট্রিং ফরম্যাটিংয়ের মতো, কিন্তু তা নয়: মানগুলো সবসময় execute()-এর দ্বিতীয় আর্গুমেন্ট হিসেবে দিন, কখনো % বা f-string দিয়ে নয়।
আইডেন্টিফায়ার প্যারামিটার হতে পারে না
প্লেসহোল্ডার কাজ করে শুধু মানের জন্য। ব্যবহারকারীর বেছে নেওয়া কলাম বা টেবিলের নাম — ড্যাশবোর্ডের "sort by" — প্যারামিটার হতে পারে না। তার বদলে একটা অনুমোদিত তালিকা (allow-list) দিয়ে যাচাই করুন:
SORTABLE = {"name": "name", "city": "city", "joined": "joined_on"}
def list_customers(sort_by):
column = SORTABLE.get(sort_by)
if column is None:
raise ValueError(f"cannot sort by {sort_by!r}")
return con.execute(f"SELECT name FROM customers ORDER BY {column}, customer_id LIMIT 3").fetchall()
print(list_customers("joined"))
try:
list_customers("name; DROP TABLE customers")
except ValueError as error:
print(error)[('Nadia Rahman',), ('Tanvir Ahmed',), ('Farhana Akter',)]
cannot sort by 'name; DROP TABLE customers'এখানে f-string নিরাপদ শুধু এজন্য যে column আপনার নিজের লেখা তিনটা স্ট্রিংয়ের একটাই হতে পারে। নিজে লেখা escape ফাংশন বা replace("'", "") দিয়ে ইনপুট "পরিষ্কার" করা কোনো প্রতিরক্ষা নয়; প্রতিরক্ষা হলো প্যারামিটার আর allow-list। দ্বিতীয় স্তর হলো ন্যূনতম অধিকার: ইনজেক্ট করা কোয়েরি কানেক্টেড রোলের অনুমতির বেশি কিছু করতে পারে না।
ক্রেডেনশিয়াল: কখনো কোডে নয়
সোর্স কোডের পাসওয়ার্ড শেষ পর্যন্ত পৌঁছে যায় Git-এর ইতিহাসে, স্ক্রিনশটে, সাহায্যের জন্য পেস্ট করা LLM প্রম্পটে, আর রিপোজিটরির প্রতিটা কপিতে। তার বদলে কানেকশনের সেটিং এনভায়রনমেন্ট থেকে পড়ুন, আর সেটিং না পেলে প্রোগ্রাম যেন স্পষ্ট এরর দিয়ে সঙ্গে সঙ্গে থেমে যায়:
import os
def database_url():
password = os.environ.get("SHOP_DB_PASSWORD")
if not password:
raise RuntimeError("SHOP_DB_PASSWORD is not set; refusing to start")
user = os.environ.get("SHOP_DB_USER", "orders_app")
host = os.environ.get("SHOP_DB_HOST", "localhost")
return f"postgresql://{user}:{password}@{host}/shop"
try:
database_url()
except RuntimeError as error:
print(error)SHOP_DB_PASSWORD is not set; refusing to startনিজের মেশিনে এগুলো রাখুন একটা .env ফাইলে, যা .gitignore-এ তালিকাভুক্ত (python-dotenv প্যাকেজ এটা লোড করে)। প্রোডাকশনে প্ল্যাটফর্মের সিক্রেট ম্যানেজার ব্যবহার করুন — AWS Secrets Manager, Google Secret Manager, Azure Key Vault, HashiCorp Vault, বা আপনার ডিপ্লয়মেন্ট প্ল্যাটফর্মের secrets — যা কোড না বদলেই পাসওয়ার্ড বদলানোর (rotate) সুযোগও দেয়। প্রতিটা অ্যাপ্লিকেশন আর মানুষকে আলাদা লগইন দিন, যাতে বাকিদের কাজ বন্ধ না করেই একটাকে বাতিল করা যায়, আর লগে দেখা যায় কে কী করেছে। কোনো সিক্রেট ফাঁস হলে সেটা বদলান; কমিট মুছে ফেলা যথেষ্ট নয়।
অ্যানালিটিক্স আর ML-এ ব্যক্তিগত ডেটা
ট্রেনিং ডেটা আর অ্যানালিটিক্সে মানুষটা কে, তা জানার দরকার খুব কমই হয় — জানা দরকার শুধু এটুকু যে দুটো সারি একই মানুষের। GDPR-এর মতো আইন (আর বাংলাদেশের নিজস্ব ডেটা সুরক্ষার নিয়মও) চায় ব্যক্তিগত ডেটা ন্যূনতম রাখা হোক। তিনটা কৌশল, দুর্বলতম থেকে শক্তিশালী:
- মাস্কিং দেখানোর সময় মানের একটা অংশ লুকায়:
n***@example.com,+880 17** *** 123। - ছদ্মনাম দেওয়া (pseudonymisation) একটা আইডেন্টিফায়ারকে একটা স্থির টোকেনে বদলে দেয়, ফলে জয়েন আর কাস্টমারপ্রতি ফিচার কাজ করে, কিন্তু টোকেন মানুষটাকে প্রকাশ করে না।
- সাধারণীকরণ আর অ্যাগ্রিগেশন: জন্মতারিখের বদলে বয়সের একটা ধাপ, পুরো ঠিকানার বদলে শুধু শহর, আলাদা আলাদা সারির বদলে মোট সংখ্যা।
সাধারণ হ্যাশ ছদ্মনাম নয়: পরিচিত ইমেইলের একটা তালিকা হ্যাশ করে যে কেউ মিলিয়ে ফেলতে পারে। প্রায়ই পড়বেন "salt দিয়ে হ্যাশ করুন" — এর দুটো আলাদা মানে হতে পারে। প্রতিটা মানের জন্য আলাদা র্যান্ডম salt, যেভাবে পাসওয়ার্ড রাখা হয় (bcrypt বা Argon2-এর মতো ধীর হ্যাশ দিয়ে), একই ইমেইলকে প্রতিবার আলাদা টোকেন দেয়, ফলে জয়েন আর কাস্টমারপ্রতি ফিচার ভেঙে যায়। ছদ্মনামের জন্য দরকার সব মানের জন্য একটাই গোপন key, আর সেটা বানানোর প্রচলিত, নিরাপদ উপায় হলো একটা key-যুক্ত হ্যাশ (HMAC); key থাকবে সিক্রেট ম্যানেজারে, কখনো ডেটার পাশে নয়:
import hashlib
import hmac
SECRET_KEY = b"demo-key-load-me-from-a-secret-manager" # আসল কোডে: os.environ[...]
def pseudonym(value):
return hmac.new(SECRET_KEY, value.lower().encode(), hashlib.sha256).hexdigest()[:16]
def mask_email(email):
user, domain = email.split("@")
return user[0] + "***@" + domain
rows = con.execute("SELECT email, city, joined_on FROM customers ORDER BY customer_id LIMIT 3").fetchall()
for email, city, joined_on in rows:
print(pseudonym(email), mask_email(email), city, joined_on[:7])
plain = hashlib.sha256(b"nadia@example.com").hexdigest()[:16]
guess = hashlib.sha256(b"nadia@example.com").hexdigest()[:16] # আক্রমণকারীর আন্দাজ
print("plain hash matched by a guess:", plain == guess)8c5c6ae09e320ce8 n***@example.com Dhaka 2025-11
e34e782d6c23800d t***@example.com Chattogram 2025-12
17b63faab2172a2a f***@example.com Dhaka 2026-01
plain hash matched by a guess: Trueঅ্যানালিটিক্সের কপিতে থাকে কাস্টমারের স্থির টোকেন, শহর আর যোগদানের মাস; ইমেইল নিজে কখনো প্রোডাকশন ডেটাবেস ছেড়ে বের হয় না। Key-টাকে ডেটার মতোই সুরক্ষিত রাখুন: যার হাতে key, সে আবারও আন্দাজ করা ইমেইল মিলিয়ে দেখতে পারে। আইনের চোখে ছদ্মনাম দেওয়া ডেটাও ব্যক্তিগত ডেটা, কারণ key দিয়ে টোকেনকে আবার মানুষের সাথে মেলানো যায়। যেসব জায়গার কথা সহজেই ভুলে যাওয়া হয়, সেখান থেকেও ব্যক্তিগত ডেটা সরান: লগ করা LLM প্রম্পট আর উত্তর, এরর মেসেজ, আর আসল সাপোর্ট টিকিট থেকে বানানো ইভ্যালুয়েশন ডেটাসেট।
ব্যাকআপ আর রিস্টোর
যে ব্যাকআপ কখনো রিস্টোর করেননি, সেটা ব্যাকআপ নয়, একটা আশা মাত্র। SQLite-এর backup API চলমান ডেটাবেস নিরাপদে কপি করে (লেখা চলার সময় সাধারণ ফাইল কপি নষ্ট হতে পারে):
import sqlite3
backup = sqlite3.connect("shop_backup.db")
con.backup(backup) # পুরো ডেটাবেসের সামঞ্জস্যপূর্ণ কপি
print("payments in backup:", backup.execute("SELECT COUNT(*) FROM payments").fetchone()[0])
con.execute("DELETE FROM payments") # বিপর্যয়: কেউ WHERE দিতে ভুলে গেছেন
con.commit()
print("payments after the mistake:", con.execute("SELECT COUNT(*) FROM payments").fetchone()[0])
backup.backup(con) # রিস্টোর: ব্যাকআপটা ফেরত কপি করুন
print("payments after restore:", con.execute("SELECT COUNT(*) FROM payments").fetchone()[0])
backup.close()payments in backup: 13
payments after the mistake: 0
payments after restore: 13sqlite3 শেল থেকে একই কাজ হলো .backup shop_backup.db আর .restore shop_backup.db। সার্ভার ডেটাবেসের সাথে আসে ডাম্প টুল:
# MySQL: একটা ডেটাবেসের সামঞ্জস্যপূর্ণ লজিক্যাল ডাম্প, তারপর নতুন একটায় রিস্টোর
mysqldump --single-transaction --routines --triggers --events shop > shop_2026-06-30.sql
mysql -e "CREATE DATABASE shop_restore_test"
mysql shop_restore_test < shop_2026-06-30.sql
# PostgreSQL: custom ফরম্যাট (সংকুচিত, টেবিল ধরে ধরে রিস্টোর করা যায়)
pg_dump --format=custom --file=shop_2026-06-30.dump shop
createdb shop_restore_test
pg_restore --dbname=shop_restore_test shop_2026-06-30.dump- ব্যাকআপ স্বয়ংক্রিয় আর নির্ধারিত সময়ে করুন, আর কপিগুলো সার্ভারের বাইরে (অন্য রিজিয়ন বা অ্যাকাউন্টে), এনক্রিপ্ট করে রাখুন।
- নিয়মিত একটা আলাদা ডেটাবেসে রিস্টোর পরীক্ষা করুন আর সারির সংখ্যা মিলিয়ে দেখুন — এই ধাপটাই সবাই এড়িয়ে যায়।
--single-transactionInnoDB টেবিল লক না করেই সামঞ্জস্যপূর্ণ স্ন্যাপশট দেয়;pg_dumpসবসময়ই একটা সামঞ্জস্যপূর্ণ স্ন্যাপশট থেকে কাজ করে।- ডেটাবেসের ডাম্পে সার্ভার-জুড়ে থাকা জিনিস থাকে না: ইউজার, রোল আর তাদের পাসওয়ার্ড। এগুলো আলাদা করে রাখুন (PostgreSQL-এ
pg_dumpall --globals-only; MySQL-এSHOW CREATE USERআরSHOW GRANTS-এর আউটপুট, বা যে সেটআপ স্ক্রিপ্ট এগুলো বানিয়েছিল)। - লজিক্যাল ডাম্প সহজ, কিন্তু বড় ডেটাবেসে ধীর। বড় সিস্টেম ফিজিক্যাল ব্যাকআপের সাথে ট্রানজ্যাকশন লগ রাখে point-in-time recovery-র জন্য ("ভুল
DELETE-এর ঠিক আগে, ১৪:৩১-এর অবস্থায় ফিরিয়ে আনুন"); ম্যানেজড ক্লাউড ডেটাবেসে এটা আপনার হয়ে করা থাকে। - লক্ষ্যগুলো জানুন: কতটা ডেটা হারানো চলবে (RPO), আর রিস্টোরে কতক্ষণ লাগতে পারবে (RTO)?
অডিট লগ আর নিরাপদ পরিবর্তন
কিছু ভুল হলে জানতে হয় কে কী করেছে। সার্ভারের লগ কানেকশন আর, চালু থাকলে, স্টেটমেন্ট লিখে রাখে (PostgreSQL-এর log_connections আর log_statement = 'ddl'; pgaudit এক্সটেনশন; MySQL Enterprise Audit বা Percona-র অডিট প্লাগইন)। গুরুত্বপূর্ণ সারির পরিবর্তনের জন্য ভিউ, স্টোরড প্রসিডিওর, ফাংশন আর ট্রিগার পাতার ট্রিগার-ভিত্তিক অডিট ট্রেইল পুরোনো আর নতুন মান লিখে রাখে। স্কিমার পরিবর্তনও হওয়া উচিত রিভিউ করা, ভার্সন করা মাইগ্রেশন দিয়ে (দেখুন আসল স্কিমা ডিজাইন, আর নিরাপদে তা বদলানো), যা চালাবে একটা ডিপ্লয়মেন্ট রোল — প্রোডাকশনে হাতে টাইপ করে নয়।
প্রোডাকশনের চেকলিস্ট
| ক্ষেত্র | যাচাই |
|---|---|
| অ্যাক্সেস | প্রতিটা অ্যাপ আর মানুষের নিজস্ব লগইন; প্রতিটা কাজের যতটুকু দরকার রোল ততটুকুই দেয়; অ্যাপ টেবিলের মালিক নয়; শেয়ার করা অ্যাডমিন পাসওয়ার্ড নেই |
| কোয়েরি | সব মান প্যারামিটার হিসেবে; আইডেন্টিফায়ার allow-list থেকে; LLM-এর বানানো SQL চলে শুধু-পড়ার রোলে, সারির সীমা আর টাইমআউটসহ |
| সিক্রেট | কোড বা নোটবুকে কোনো ক্রেডেনশিয়াল নেই; সিক্রেট থাকে এনভায়রনমেন্টে বা সিক্রেট ম্যানেজারে; বদলানো সম্ভব |
| নেটওয়ার্ক | ইন্টারনেট থেকে ডেটাবেসে পৌঁছানো যায় না; কানেকশনে TLS; MySQL ইউজারে host-এর সীমা |
| ব্যক্তিগত ডেটা | অ্যানালিস্ট আর ML পাইপলাইন দেখে ভিউ বা ছদ্মনামের কপি; লগ আর প্রম্পট পরিষ্কার; ডেটা রাখার সময়সীমা |
| ব্যাকআপ | স্বয়ংক্রিয়, অন্য জায়গায়, এনক্রিপ্টেড; নির্ধারিত সময়ে রিস্টোর পরীক্ষা; RPO/RTO জানা |
| পরিবর্তন | মাইগ্রেশন রিভিউ করা আর ভার্সন করা; ধ্বংসাত্মক স্টেটমেন্ট চলে SELECT দিয়ে যাচাইয়ের পর, একটা ট্রানজ্যাকশনের ভেতরে |
| নজরদারি | অডিট লগ চালু; ব্যর্থ লগইন, অধিকার বদল আর অস্বাভাবিক এক্সপোর্টে সতর্কবার্তা |
সাধারণ ভুল
- অ্যাপ কানেক্ট করে superuser বা
rootহিসেবে। তখন একটা ইনজেকশন বা বাগেই সব মুছে যেতে পারে। শুধু দরকারি গ্রান্টসহ একটা অ্যাপ্লিকেশন রোল বানান (ওপরেরorders_app-এর মতো)। - "ইনপুট তো বিশ্বস্ত" ভেবে f-string দিয়ে SQL বানানো। অন্য টেবিল, ফাইল বা LLM থেকে আসা ইনপুটও বিশ্বস্ত নয়। প্রতিবার প্যারামিটার ব্যবহার করুন:
con.execute("… WHERE email = ?", (email,))। - PyMySQL বা psycopg-এ
%ব্যবহার।cur.execute("… WHERE id = %s" % user_id)হলো স্ট্রিং ফরম্যাটিং, প্যারামিটার নয়। লিখুনcur.execute("… WHERE id = %s", (user_id,))। - key ছাড়া ইমেইল হ্যাশ করে সেটাকে বেনামি বলা। ইমেইলের সাধারণ SHA-256 আন্দাজ করে উল্টানো যায়। গোপন key-সহ HMAC ব্যবহার করুন, আর মনে রাখুন ফলাফলটাও ব্যক্তিগত ডেটা।
- কখনো রিস্টোর না করা ব্যাকআপ। রিস্টোর পরীক্ষার সময় ঠিক করুন, আর প্রোডাকশনের সাথে সারির সংখ্যা মিলিয়ে দেখুন।
নিজে চেষ্টা করুন
- সহজ: এই ফাংশনে ইনজেকশন সম্ভব:
con.execute(f"SELECT name FROM products WHERE name LIKE '%{term}%'")। একটা প্যারামিটার দিয়ে নতুন করে লিখুন (ইঙ্গিত: প্যাটার্নটা Python-এ বানান), আর দেখান এটা"Mouse"খুঁজে পায়, কিন্তু"x' OR '1'='1"-এ কিছুই পায় না। - মাঝারি: PostgreSQL-এ
support_agentনামে একটা রোল বানান, যাordersপড়তে পারে আর শুধুstatusকলাম আপডেট করতে পারে (কলাম-স্তরের গ্রান্ট), আর দেখান যেorder_dateআপডেট করতে গেলে ফিরিয়ে দেওয়া হয়। - কঠিন: SQLite-এ ছদ্মনাম দেওয়া একটা ট্রেনিং টেবিল বানান:
customer_features(customer_token, city, orders, spent), যেখানেcustomer_tokenহলো ইমেইলের HMAC ছদ্মনাম।con.create_functionদিয়েpseudonym-কে SQL ফাংশন হিসেবে রেজিস্টার করুন, যাতে পুরো টেবিলটা একটাইCREATE TABLE … AS SELECTদিয়ে বানানো যায়।
উত্তর
# ১. সহজ
def search_products(term):
return con.execute("SELECT name FROM products WHERE name LIKE ? ORDER BY name",
(f"%{term}%",)).fetchall()
print(search_products("Mouse"))
print(search_products("x' OR '1'='1"))-- PostgreSQL
-- ২. মাঝারি
RESET ROLE;
DROP ROLE IF EXISTS support_agent;
CREATE ROLE support_agent LOGIN PASSWORD 'change-me-000';
GRANT SELECT ON orders TO support_agent;
GRANT UPDATE (status) ON orders TO support_agent; -- কলাম-স্তরের অধিকার: শুধু status বদলানো যাবে
SET ROLE support_agent;
UPDATE orders SET status = 'delivered' WHERE order_id = 12;
UPDATE orders SET order_date = '2026-01-01' WHERE order_id = 12;Error: permission denied for table orders-- PostgreSQL
RESET ROLE; -- গুছিয়ে রাখা: রোল পুরো সার্ভারের
DROP OWNED BY support_agent;
DROP ROLE support_agent;# ৩. কঠিন
con.create_function("pseudonym", 1, pseudonym, deterministic=True)
con.execute("DROP TABLE IF EXISTS customer_features")
con.execute("""
CREATE TABLE customer_features AS
SELECT pseudonym(c.email) AS customer_token,
c.city,
COUNT(DISTINCT o.order_id) AS orders, -- DISTINCT: items would fan out
COALESCE(SUM(oi.quantity * oi.unit_price), 0) AS spent
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id AND o.status <> 'cancelled'
LEFT JOIN order_items oi ON oi.order_id = o.order_id
GROUP BY c.customer_id""")
con.commit()
show("SELECT * FROM customer_features ORDER BY spent DESC, customer_token LIMIT 3")সারসংক্ষেপ
- অধিকার দিন রোলকে, প্রতিটা অ্যাপ আর মানুষকে দিন নিজস্ব লগইন, আর শুধু কাজের জন্য যা দরকার তা-ই দিন; সংবেদনশীল কলাম লুকাতে ভিউ ব্যবহার করুন।
REVOKEঅ্যাক্সেস ফিরিয়ে নেয়। - SQL ইনজেকশন ইনপুটকে কোডে বদলে দেয়। প্যারামিটার (
?,%s) মানকে ডেটা হিসেবেই রাখে; আইডেন্টিফায়ার আসে allow-list থেকে। - ক্রেডেনশিয়াল থাকে এনভায়রনমেন্টে বা সিক্রেট ম্যানেজারে, কখনো কোড বা নোটবুকে নয়।
- অ্যানালিটিক্স আর ML-এর সাধারণত দরকার একটা স্থির টোকেন, পরিচয় নয়: দেখানোর জন্য মাস্ক করুন, গোপন key-সহ HMAC দিয়ে ছদ্মনাম দিন, সাধারণীকরণ আর অ্যাগ্রিগেট করুন।
- স্বয়ংক্রিয়ভাবে ব্যাকআপ নিন, কপি অন্য জায়গায় রাখুন, আর প্রমাণ করুন যে রিস্টোর করতে পারেন।
এরপর: ডেটা হ্যান্ডলিংয়ের মূল কথা: ফরম্যাট, মান আর উৎস পাতায় ডেটাবেস থেকে এক পা সরে এসে কথা হবে ডেটা নিয়েই: এটা কোথা থেকে আসে, কোন ফরম্যাটে পৌঁছায়, এর মান কীভাবে বিচার করবেন, আর কীভাবে প্রসেস করবেন যাতে প্রতিটা ফলাফলের উৎস খুঁজে পাওয়া আর আবার তৈরি করা যায়।