Step 22 — 사용자와 보안
학습 목표
- 사용자를 만들고 인증 플러그인(
caching_sha2_password vs mysql_native_password)을 이해한다
- 권한을 4개 레벨(글로벌/DB/테이블/컬럼)로 나누어 최소권한 원칙대로 부여한다
- MySQL 8.0 신기능 롤(ROLE) 로 권한을 역할 단위로 관리한다
- 계정 잠금(
FAILED_LOGIN_ATTEMPTS), 비밀번호 만료·재사용 제한을 건다
- 뷰로 컬럼을 마스킹하고, SQL Injection 이 DB 관점에서 왜 치명적인지 이해한다
선행 스텝: Step 14 — 뷰
예상 소요: 60분
⚠️ 이 스텝의 SQL 은 root 로 실행합니다: mysql -h127.0.0.1 -P3307 -uroot -proot1234 shop
실습이 끝나면 만든 계정/롤/뷰를 전부 삭제합니다(공용 서버이므로).
22-1. 사용자 = '이름'@'호스트'
MySQL 의 사용자는 이름과 접속 호스트의 조합입니다. 'app'@'10.0.0.5' 와 'app'@'%' 는 다른 계정입니다.
CREATE USER 'app_ro'@'%' IDENTIFIED WITH caching_sha2_password BY 'Ro#Pass123';
CREATE USER 's22_native'@'%' IDENTIFIED WITH mysql_native_password BY 'Nat#Pass123';
SELECT user, host, plugin FROM mysql.user
WHERE user IN ('app_ro','app_rw','s22_native') ORDER BY user;
결과
+------------+------+-----------------------+
| user | host | plugin |
+------------+------+-----------------------+
| app_ro | % | caching_sha2_password |
| app_rw | % | caching_sha2_password |
| s22_native | % | mysql_native_password |
+------------+------+-----------------------+
| 인증 플러그인 | 설명 |
|---|
caching_sha2_password | MySQL 8.0 기본값. SHA-256 해시 + 서버측 캐시. TLS 또는 RSA 키 교환으로 비밀번호 보호. 이걸 쓰세요. |
mysql_native_password | 5.x 시절 방식(SHA-1 기반). 구형 드라이버 호환용. 8.0 에서 비권장, 8.4 에서 기본 비활성, 9.x 에서 제거 예정. |
⚠️ 함정 — 'app'@'%' 는 "어느 IP 에서든 접속 허용"입니다. 편하지만 위험합니다.
운영에서는 애플리케이션 서버 대역('app'@'10.0.1.%')으로 호스트를 좁히세요. 이것도 최소권한입니다.
💡 실무 팁 — caching_sha2_password 는 첫 접속 시 TLS 나 RSA 공개키가 필요합니다.
평문 비TLS 연결에서 "Authentication plugin ... reported error" 가 나면 인증 방식이 원인일 때가 많습니다.
드라이버가 caching_sha2 를 지원하는지 먼저 확인하세요(옛 커넥터는 지원 안 함).
22-2. 롤(ROLE) — 8.0 신기능
8.0 이전에는 권한을 사람마다 일일이 부여했습니다. 담당자 10명이면 GRANT 를 10번. 담당자가 바뀌면 또 REVOKE/GRANT.
8.0 의 롤은 권한을 "역할"에 모아두고, 역할을 사람에게 줍니다.
CREATE ROLE s22_reader, s22_writer, s22_analyst;
읽기전용 담당자 ──┐
├──> [s22_reader 롤] ──> GRANT SELECT ON shop.*
백엔드 서버 ────┤
└──> [s22_writer 롤] ──> GRANT SELECT,INSERT,UPDATE,DELETE ON shop.*
22-3. 권한 레벨 4단계 — 최소권한 원칙
권한은 넓은 것부터 좁은 것까지 4단계로 줄 수 있습니다. 항상 가장 좁게 주세요.
GRANT SELECT ON *.* TO ...; -- ① 글로벌 (모든 DB) — 거의 쓰지 마라
GRANT SELECT, INSERT, UPDATE, DELETE ON shop.* TO s22_writer; -- ② DB 레벨
GRANT SELECT ON shop.orders TO s22_analyst; -- ③ 테이블 레벨
GRANT SELECT (customer_id, grade, city, points)
ON shop.customers TO s22_analyst; -- ④ 컬럼 레벨
컬럼 레벨 권한이 실제로 막는지 확인해 봅시다. s22_analyst 롤을 받은 s22_alice 로 로그인:
mysql -h127.0.0.1 -P3307 -us22_alice -pAlice#123 shop \
-e "SELECT customer_id, grade, city, points FROM customers LIMIT 3;"
+-------------+--------+--------+--------+
| customer_id | grade | city | points |
+-------------+--------+--------+--------+
| 1 | VIP | 서울 | 12500 |
| 2 | GOLD | 서울 | 8300 |
| 3 | SILVER | 부산 | 3100 |
+-------------+--------+--------+--------+
허용되지 않은 컬럼(email)이나 SELECT * 는 거부됩니다:
mysql ... -us22_alice ... -e "SELECT email FROM customers LIMIT 1;"
# ERROR 1143 (42000): SELECT command denied to user 's22_alice'@'...' for column 'email' in table 'customers'
mysql ... -us22_alice ... -e "SELECT * FROM customers LIMIT 1;"
# ERROR 1142 (42000): SELECT command denied to user 's22_alice'@'...' for table 'customers'
mysql ... -us22_alice ... -e "SELECT * FROM products LIMIT 1;"
# ERROR 1142 (42000): SELECT command denied ... for table 'products'
💡 실무 팁 — SELECT * 가 거부되는 이유: * 는 email, phone 을 포함하는데 그 컬럼 권한이 없기 때문입니다.
컬럼 레벨 권한을 쓰면 애플리케이션이 컬럼을 명시하도록 강제되는 부수 효과가 있습니다(좋은 습관).
22-4. 롤 부여 + 기본 롤 활성화 (여기서 많이 막힌다)
GRANT s22_reader TO 'app_ro'@'%';
GRANT s22_reader, s22_writer TO 'app_rw'@'%';
-- ★ 롤은 "부여"만으로 켜지지 않는다. 로그인 시 자동 활성화되도록 기본 롤 지정
SET DEFAULT ROLE ALL TO 'app_ro'@'%', 'app_rw'@'%';
⚠️ 함정 — GRANT role TO user 만 하고 SET DEFAULT ROLE 를 빠뜨리면,
그 유저는 로그인해도 아무 권한이 없습니다(CURRENT_ROLE() 가 NONE).
세션에서 직접 SET ROLE ... 로 켜야 하죠. 서버 전역 activate_all_roles_on_login=ON 으로 바꾸면
항상 켜지지만, 공용 서버의 전역 설정은 건드리지 않습니다.
SHOW GRANTS FOR 'app_ro'@'%';
결과
+----------------------------------------+
| Grants for app_ro@% |
+----------------------------------------+
| GRANT USAGE ON *.* TO `app_ro`@`%` | ← USAGE = "로그인만 가능, 권한 없음"
| GRANT `s22_reader`@`%` TO `app_ro`@`%` |
+----------------------------------------+
SHOW GRANTS ... USING <role> 로 특정 롤을 켠 상태의 유효 권한을 펼쳐 볼 수 있습니다:
SHOW GRANTS FOR 'app_rw'@'%' USING s22_writer;
+------------------------------------------------------------------+
| Grants for app_rw@% |
+------------------------------------------------------------------+
| GRANT USAGE ON *.* TO `app_rw`@`%` |
| GRANT SELECT, INSERT, UPDATE, DELETE ON `shop`.* TO `app_rw`@`%` |
| GRANT `s22_reader`@`%`,`s22_writer`@`%` TO `app_rw`@`%` |
+------------------------------------------------------------------+
읽기 전용 계정이 쓰기를 시도하면 거부됩니다:
mysql ... -uapp_ro -pRo#Pass123 shop \
-e "INSERT INTO reviews(product_id,customer_id,rating,created_at) VALUES(1,1,5,NOW());"
# ERROR 1142 (42000): INSERT command denied to user 'app_ro'@'...' for table 'reviews'
22-5. 뷰를 통한 컬럼 마스킹
민감정보를 원본 테이블 대신 가려진 뷰로만 노출합니다. 분석가에게는 customers 원본 권한을 안 주고, 뷰만 줍니다.
CREATE VIEW shop.s22_customers_masked AS
SELECT
customer_id, name,
CONCAT(LEFT(email, 2), '***@', SUBSTRING_INDEX(email, '@', -1)) AS email_masked,
CONCAT(LEFT(phone, 3), '-****-', RIGHT(phone, 4)) AS phone_masked,
grade, city
FROM shop.customers;
GRANT SELECT ON shop.s22_customers_masked TO s22_analyst;
결과 (분석가 계정으로 조회)
+-------------+-----------+-------------------+---------------+--------+--------+
| customer_id | name | email_masked | phone_masked | grade | city |
+-------------+-----------+-------------------+---------------+--------+--------+
| 1 | 김민수 | ki***@example.com | 010-****-0001 | VIP | 서울 |
| 2 | 이지은 | le***@example.com | 010-****-0002 | GOLD | 서울 |
| 3 | 박철수 | pa***@example.com | 010-****-0003 | SILVER | 부산 |
+-------------+-----------+-------------------+---------------+--------+--------+
뷰는 정의자(definer) 권한으로 실행되므로, 뷰를 통해서는 원본을 읽지만 분석가가 직접 customers 를 SELECT 하면 거부됩니다.
이것이 뷰 기반 접근 통제의 핵심입니다.
💡 실무 팁 — MySQL 8.0.34+ 에는 정식 Data Masking 컴포넌트(엔터프라이즈)가 있지만,
뷰 마스킹은 어느 에디션에서나 됩니다. 마스킹 규칙이 자주 바뀌면 뷰가 관리하기 쉽습니다.
22-6. 비밀번호 정책 & 계정 잠금
서버 전역 정책(validate_password 컴포넌트)은 공용 서버라 설치하지 않습니다. (참고: INSTALL COMPONENT 'file://component_validate_password'; 로 설치하면 길이/대소문자/숫자/특수문자 규칙을 전역 강제)
대신 계정 단위 정책은 자유롭게 겁니다.
계정 잠금 — FAILED_LOGIN_ATTEMPTS (8.0.19+)
CREATE USER 's22_lock'@'%' IDENTIFIED BY 'Lock#Pass123'
FAILED_LOGIN_ATTEMPTS 3 -- 3회 연속 실패하면
PASSWORD_LOCK_TIME 1; -- 1일간 잠금
3번 틀린 뒤에는 올바른 비밀번호로도 로그인이 막힙니다:
# 3회 오답
mysql ... -us22_lock -pWRONG -e "SELECT 1" # ERROR 1045 (Access denied)
mysql ... -us22_lock -pWRONG -e "SELECT 1" # ERROR 1045
mysql ... -us22_lock -pWRONG -e "SELECT 1"
# ERROR 3955 (HY000): Account is blocked for 1 day(s) (1 day(s) remaining) due to 3 consecutive failed logins.
# 이제 올바른 비밀번호도 막힌다
mysql ... -us22_lock -pLock#Pass123 -e "SELECT 1"
# ERROR 3955 (HY000): Account is blocked ...
잠긴 계정 해제:
ALTER USER 's22_lock'@'%' ACCOUNT UNLOCK;
비밀번호 만료 / 재사용 제한
ALTER USER 's22_lock'@'%' PASSWORD EXPIRE; -- 다음 로그인 때 강제 변경
ALTER USER 's22_lock'@'%' PASSWORD HISTORY 3 -- 최근 3개
PASSWORD REUSE INTERVAL 365 DAY; -- 또는 1년 내 재사용 금지
같은 비밀번호로 다시 바꾸려 하면 거부됩니다:
ALTER USER 's22_lock'@'%' IDENTIFIED BY 'NewPass#111'; -- 첫 변경: OK
ALTER USER 's22_lock'@'%' IDENTIFIED BY 'NewPass#111'; -- 재사용 시도
-- ERROR 3638 (HY000): Cannot use these credentials for 's22_lock@%'
-- because they contradict the password history policy
계정에 걸린 정책은 mysql.user.User_attributes(JSON)에서 확인합니다:
SELECT user, host,
JSON_EXTRACT(User_attributes, '$.Password_locking') AS lock_policy,
password_expired
FROM mysql.user WHERE user = 's22_lock';
+----------+------+------------------------------------------------------------+------------------+
| user | host | lock_policy | password_expired |
+----------+------+------------------------------------------------------------+------------------+
| s22_lock | % | {"failed_login_attempts": 3, "password_lock_time_days": 1} | Y |
+----------+------+------------------------------------------------------------+------------------+
22-7. REVOKE
REVOKE INSERT, UPDATE, DELETE ON shop.* FROM s22_writer;
SHOW GRANTS FOR s22_writer;
+----------------------------------------------+
| Grants for s22_writer@% |
+----------------------------------------------+
| GRANT USAGE ON *.* TO `s22_writer`@`%` |
| GRANT SELECT ON `shop`.* TO `s22_writer`@`%` | ← 쓰기 권한이 회수됨
+----------------------------------------------+
💡 실무 팁 — 롤에서 권한을 회수하면 그 롤을 가진 모든 유저에게 즉시 반영됩니다.
사람마다 REVOKE 할 필요가 없다는 것이 롤의 최대 장점입니다.
22-8. SQL Injection 이 DB 관점에서 왜 치명적인가
애플리케이션이 사용자 입력을 문자열로 이어붙여 쿼리를 만들면("... WHERE email='" + input + "'"),
입력값 ' OR '1'='1 하나로 WHERE 절 전체가 무력화됩니다. DB 는 문법적으로 올바른 쿼리를 충실히 실행할 뿐입니다.
정상 : SELECT * FROM users WHERE email='a@b.com' AND pw='...'
공격 : SELECT * FROM users WHERE email='' OR '1'='1' -- ' AND pw='...'
▲ 항상 참 ▲ 뒤는 주석 처리
→ 전체 사용자 유출. UNION SELECT 로 다른 테이블까지, ; DROP TABLE 로 파괴까지.
DB 관점의 방어선(애플리케이션의 프리페어드 스테이트먼트와 별개로 반드시 병행):
- 최소권한 — 웹 앱 계정에
DROP/ALTER/FILE 권한이 없으면, 인젝션이 나도 테이블을 못 지운다. 이 스텝의 app_rw 처럼 DML 만 주면 피해가 데이터 조회/변조로 한정된다.
- 컬럼/뷰 마스킹 — 인젝션으로
customers 를 통째로 읽어도, 앱 계정이 마스킹 뷰만 볼 수 있으면 평문 이메일/전화번호는 새지 않는다.
- 계정 분리 — 읽기 API 는
app_ro, 쓰기 API 는 app_rw. 읽기 경로의 인젝션으로는 쓰기 자체가 불가능하다.
- 감사(audit) — 누가 언제 무엇을 했는지 로그가 있어야 사고 후 추적이 된다.
⚠️ 함정 — "프리페어드 스테이트먼트만 쓰면 안전하다"는 절반만 맞습니다.
정렬 컬럼명, 테이블명, LIMIT 등은 바인딩할 수 없어 여전히 문자열 조립이 필요합니다(화이트리스트로 검증해야 함).
그래서 DB 최소권한이 마지막 방어선으로 반드시 있어야 합니다.
감사 관점
-- 접속 시도/쿼리를 남기는 방법 (개념)
-- * 엔터프라이즈: MySQL Enterprise Audit (audit_log 컴포넌트)
-- * 커뮤니티 : general_log(성능 영향 큼, 임시 진단용) 또는
-- Percona/MariaDB audit plugin, 또는 프록시(ProxySQL) 레벨 로깅
SELECT user, host, plugin, password_last_changed, password_expired, account_locked
FROM mysql.user WHERE user NOT LIKE 'mysql.%';
정기적으로 "권한이 과한 계정", "오래 안 바꾼 비밀번호", "쓰지 않는 계정" 을 감사해야 합니다.
정리
| 항목 | 요점 |
|---|
| 사용자 | '이름'@'호스트'. 호스트를 좁히는 것도 최소권한 |
| 인증 플러그인 | caching_sha2_password(8.0 기본, 권장) vs mysql_native_password(레거시) |
| 권한 레벨 | 글로벌 > DB > 테이블 > 컬럼. 항상 가장 좁게 |
| 롤(8.0) | CREATE ROLE → GRANT 권한 TO 롤 → GRANT 롤 TO 유저 → SET DEFAULT ROLE |
| 롤 함정 | SET DEFAULT ROLE 를 빠뜨리면 로그인해도 권한 0 |
| 마스킹 | 민감 컬럼은 뷰로 가리고, 원본 테이블 권한은 주지 않는다 |
| 계정 잠금 | FAILED_LOGIN_ATTEMPTS N PASSWORD_LOCK_TIME D (8.0.19+), ERROR 3955 |
| 비밀번호 | PASSWORD EXPIRE / PASSWORD HISTORY / PASSWORD REUSE INTERVAL |
| SQL Injection | DB 최소권한이 마지막 방어선. 읽기/쓰기 계정 분리, 마스킹, 감사 |
| 8.0/8.4 변경 | validate_password 는 플러그인→컴포넌트로. mysql_native_password 는 8.4 기본 비활성 |
연습문제
exercise.sql 을 열어 풀어 보세요. 정답은 solution.sql.
- 배치 잡 전용 계정
s22_batch@'%' 를 만들고, orders 와 order_items 에 대해서만 SELECT/INSERT 를 주는 롤 s22_batch_role 을 통해 권한을 부여하라.
s22_batch 가 customers 를 SELECT 하면 거부되는 것을 확인하라(개념: 어떤 에러 번호?).
products 에서 cost(원가)를 제외한 모든 컬럼만 읽는 컬럼 레벨 권한을 롤에 부여하라.
s22_temp@'%' 를 만들되, 연속 실패 5회 시 2일 잠금 + 생성 즉시 비밀번호 만료로 설정하라.
s22_batch_role 에서 INSERT 권한만 회수(REVOKE)하고 남은 권한을 확인하라.
- 실습에서 만든 모든 계정/롤을 삭제하는 정리 SQL 을 작성하라.
다음 단계
→ Step 23 — 백업과 복제
실습 파일
이 스텝은 SQL 파일 3개로 구성됩니다. 먼저 practice.sql 을 실행해 계정 생성 → 롤 → 권한 → 마스킹 뷰 → 비밀번호 정책 → REVOKE → 뒷정리의 흐름을 그대로 재현해 보고, 그다음 exercise.sql 의 6문제를 직접 채워 넣은 뒤, 마지막으로 solution.sql 과 비교합니다. 세 파일 모두 root 로 실행해야 합니다(계정·롤 생성은 관리 권한이 필요합니다).
⚠️ 뒷정리 주의 — practice.sql 과 solution.sql 은 마지막에 만든 계정/롤/뷰를 전부 지우는 정리 블록이 이미 들어 있어 그대로 실행됩니다. 하지만 exercise.sql 의 정리 블록(문제 6)은 주석 처리된 TODO 일 뿐이라 아무것도 지우지 않습니다. 공용 서버이므로 문제 6은 여러분이 직접 작성해서 반드시 실행해야 합니다.
💡 practice.sql 안의 절 번호는 본문과 한 칸씩 어긋납니다. 파일의 22-6(마스킹 뷰)이 본문 22-5, 파일의 22-7(비밀번호 정책)이 본문 22-6, 파일의 22-8(REVOKE)이 본문 22-7 에 해당하고, 파일의 22-9(뒷정리)는 본문에 대응하는 절이 없습니다.
practice.sql
본문 강의를 그대로 따라가는 메인 실습 스크립트입니다. 실행은 mysql -h127.0.0.1 -P3307 -uroot -proot1234 shop -t --force < practice.sql.
- 맨 앞의
DROP VIEW/ROLE/USER IF EXISTS 블록이 이전 실행 잔재를 지우므로, 몇 번을 다시 돌려도 같은 결과가 나옵니다(멱등). --force 옵션은 중간에 에러가 나도 끝까지 진행시켜, 마지막 정리 블록이 반드시 실행되게 하려는 의도입니다.
- 22-1 구간에서
app_ro/app_rw 는 IDENTIFIED WITH caching_sha2_password, s22_native 는 IDENTIFIED WITH mysql_native_password 로 만들어 mysql.user.plugin 컬럼에서 두 인증 방식의 차이를 눈으로 확인하게 합니다.
- 22-3 구간이 권한 레벨의 핵심입니다.
GRANT SELECT ON shop.* TO s22_reader(DB 레벨), GRANT SELECT ON shop.orders TO s22_analyst(테이블 레벨), GRANT SELECT (customer_id, grade, city, points) ON shop.customers TO s22_analyst(컬럼 레벨)가 나란히 놓여 범위가 좁아지는 과정을 보여줍니다. 다만 본문 22-3 이 소개한 4레벨 중 글로벌 레벨(ON *.*)은 스크립트에 일부러 넣지 않았습니다 — "거의 쓰지 말라"고 한 권한을 실습 서버에서 실제로 부여할 이유가 없기 때문입니다. 그래서 파일에서 실행되는 것은 DB/테이블/컬럼 3레벨뿐입니다. s22_analyst 에는 email·phone 컬럼이 빠져 있어서, 이 롤을 받은 계정은 SELECT * 조차 ERROR 1142 로 거부됩니다.
- 22-5 구간의
SHOW GRANTS FOR 'app_rw'@'%' USING s22_writer; 는 롤을 켠 상태의 유효 권한을 펼쳐 보여 줍니다. 바로 앞줄의 SHOW GRANTS FOR 'app_ro'@'%';(롤 미적용) 출력과 비교하면, 롤이 활성화되기 전에는 USAGE(권한 없음)만 보인다는 점이 드러납니다.
- 51번째 줄의
SET DEFAULT ROLE ALL TO 'app_ro'@'%', 'app_rw'@'%'; 가 본문 22-4 의 "많이 막히는 함정"에 해당합니다. 이 한 줄이 없으면 GRANT s22_reader TO 'app_ro'@'%' 를 해 두고도 로그인 시 CURRENT_ROLE() 이 NONE 이라 권한이 하나도 없는 상태가 됩니다.
- 주의: 스크립트에는
s22_alice 를 만드는 문장이 없고 DROP 문에만 들어 있습니다. 본문 22-3 의 컬럼 권한 검증(-us22_alice)을 직접 해 보려면 CREATE USER 's22_alice'@'%' IDENTIFIED BY 'Alice#123'; + GRANT s22_analyst TO 's22_alice'@'%'; + SET DEFAULT ROLE ALL TO 's22_alice'@'%'; 를 별도 세션에서 추가해야 합니다. 계정 잠금(ERROR 3955)이나 로그인 거부처럼 접속 자체를 시도해야 보이는 결과는 SQL 파일만으로는 재현되지 않으므로, 본문의 셸 명령을 함께 실행해 보세요.
- 마지막 22-9 블록은 뷰·롤·계정을 다시 전부 DROP 하고,
leftover_users 가 0 인지 세어서 뒷정리가 끝났음을 스스로 검증합니다.
-- =====================================================================
-- Step 22 — 사용자와 보안 : practice.sql
-- 실행: mysql -h127.0.0.1 -P3307 -uroot -proot1234 shop -t --force < practice.sql
-- ※ 반드시 root 로 실행합니다 (사용자/롤 생성은 관리 권한 필요).
-- ※ 이 스크립트는 서버에 계정을 "만들고, 검증하고, 마지막에 전부 지웁니다".
-- 공용 스키마에는 s22_ 접두사 뷰 하나만 잠깐 만들었다가 지웁니다.
-- ※ 계정 검증(로그인 성공/거부)은 SQL 만으로는 보여줄 수 없어
-- README 에 셸 명령과 실제 출력을 함께 실었습니다.
-- =====================================================================
-- ---------------------------------------------------------------------
-- 0. 깨끗한 시작 (이전 실행 잔재 제거)
-- ---------------------------------------------------------------------
DROP VIEW IF EXISTS shop.s22_customers_masked;
DROP ROLE IF EXISTS s22_reader, s22_writer, s22_analyst;
DROP USER IF EXISTS 'app_ro'@'%', 'app_rw'@'%', 's22_alice'@'%',
's22_native'@'%', 's22_lock'@'%';
-- ---------------------------------------------------------------------
-- 22-1. 사용자 생성 + 인증 플러그인
-- caching_sha2_password : MySQL 8.0 기본. SHA-256 + 캐시. 안전.
-- mysql_native_password : 예전 방식. 구형 드라이버 호환용. 8.0 에서 비권장.
-- (8.4 에서 기본 비활성, 9.x 에서 제거 예정)
-- ---------------------------------------------------------------------
CREATE USER 'app_ro'@'%' IDENTIFIED WITH caching_sha2_password BY 'Ro#Pass123';
CREATE USER 'app_rw'@'%' IDENTIFIED WITH caching_sha2_password BY 'Rw#Pass123';
CREATE USER 's22_native'@'%' IDENTIFIED WITH mysql_native_password BY 'Nat#Pass123';
SELECT user, host, plugin FROM mysql.user
WHERE user IN ('app_ro','app_rw','s22_native') ORDER BY user;
-- ---------------------------------------------------------------------
-- 22-2. 롤(ROLE) — MySQL 8.0 신기능
-- 권한을 "역할"에 모아두고, 역할을 사람에게 부여한다.
-- 담당자가 바뀌어도 롤만 갈아끼우면 된다. (권한을 사람마다 일일이 안 준다)
-- ---------------------------------------------------------------------
CREATE ROLE s22_reader, s22_writer, s22_analyst;
-- 22-3. 권한 레벨 : 글로벌 / DB / 테이블 / 컬럼
-- 최소권한 원칙: 필요한 것만, 필요한 범위만.
GRANT SELECT ON shop.* TO s22_reader; -- DB 레벨
GRANT SELECT, INSERT, UPDATE, DELETE ON shop.* TO s22_writer; -- DB 레벨
GRANT SELECT ON shop.orders TO s22_analyst; -- 테이블 레벨
GRANT SELECT (customer_id, grade, city, points)
ON shop.customers TO s22_analyst; -- 컬럼 레벨!
-- 22-4. 롤을 유저에게 부여하고 기본 롤 활성화
GRANT s22_reader TO 'app_ro'@'%';
GRANT s22_reader, s22_writer TO 'app_rw'@'%';
-- ★ 중요: 롤은 부여만 해서는 "활성화"되지 않는다. 로그인 시 켜지도록 기본 롤을 지정.
SET DEFAULT ROLE ALL TO 'app_ro'@'%', 'app_rw'@'%';
-- 22-5. 권한 확인
SHOW GRANTS FOR 'app_ro'@'%';
SHOW GRANTS FOR 'app_rw'@'%' USING s22_writer; -- 특정 롤을 켠 상태의 유효 권한
-- ---------------------------------------------------------------------
-- 22-6. 뷰를 통한 컬럼 마스킹
-- 민감정보(email/phone)를 원본 테이블 대신 "가려진 뷰"로만 노출한다.
-- analyst 에게는 customers 원본 테이블 권한을 주지 않고, 뷰만 준다.
-- ---------------------------------------------------------------------
CREATE VIEW shop.s22_customers_masked AS
SELECT
customer_id,
name,
CONCAT(LEFT(email, 2), '***@', SUBSTRING_INDEX(email, '@', -1)) AS email_masked,
CONCAT(LEFT(phone, 3), '-****-', RIGHT(phone, 4)) AS phone_masked,
grade, city
FROM shop.customers;
GRANT SELECT ON shop.s22_customers_masked TO s22_analyst;
SELECT * FROM shop.s22_customers_masked LIMIT 3;
-- ---------------------------------------------------------------------
-- 22-7. 비밀번호 정책 (계정 단위)
-- 서버 전역 정책(validate_password 컴포넌트)은 공용 서버라 건드리지 않는다.
-- 대신 "계정 단위" 정책은 자유롭게 걸 수 있다.
-- ---------------------------------------------------------------------
-- 계정 잠금 : 연속 실패 N회 시 잠금 (FAILED_LOGIN_ATTEMPTS, 8.0.19+)
CREATE USER 's22_lock'@'%' IDENTIFIED BY 'Lock#Pass123'
FAILED_LOGIN_ATTEMPTS 3 -- 3회 연속 틀리면
PASSWORD_LOCK_TIME 1; -- 1일간 잠금 (N=UNBOUNDED 면 무기한)
-- 비밀번호 만료 : 다음 로그인 때 강제 변경
ALTER USER 's22_lock'@'%' PASSWORD EXPIRE;
-- 비밀번호 재사용 제한 : 최근 3개 / 365일 이내 재사용 금지
ALTER USER 's22_lock'@'%' PASSWORD HISTORY 3 PASSWORD REUSE INTERVAL 365 DAY;
-- 계정에 걸린 정책 확인 (mysql.user.User_attributes 는 JSON)
SELECT user, host,
JSON_EXTRACT(User_attributes, '$.Password_locking') AS lock_policy,
password_expired
FROM mysql.user WHERE user = 's22_lock';
-- 잠긴 계정 해제
ALTER USER 's22_lock'@'%' ACCOUNT UNLOCK;
-- ---------------------------------------------------------------------
-- 22-8. REVOKE — 권한 회수
-- ---------------------------------------------------------------------
REVOKE INSERT, UPDATE, DELETE ON shop.* FROM s22_writer;
SHOW GRANTS FOR s22_writer; -- 이제 SELECT 만 남는다
-- ---------------------------------------------------------------------
-- 22-9. 뒷정리 — 만든 계정/롤/뷰를 전부 제거
-- (공용 서버이므로 실습 흔적을 반드시 지운다)
-- ---------------------------------------------------------------------
DROP VIEW IF EXISTS shop.s22_customers_masked;
DROP ROLE IF EXISTS s22_reader, s22_writer, s22_analyst;
DROP USER IF EXISTS 'app_ro'@'%', 'app_rw'@'%', 's22_alice'@'%',
's22_native'@'%', 's22_lock'@'%';
SELECT 'cleanup done' AS status,
(SELECT COUNT(*) FROM mysql.user
WHERE user LIKE 's22%' OR user IN ('app_ro','app_rw')) AS leftover_users;
exercise.sql
연습문제 6개가 담긴 빈칸 채우기 스크립트입니다. TODO 주석 아래의 힌트 SQL이 모두 -- 로 주석 처리되어 있으므로, 그대로 실행하면 앞부분의 DROP 문만 돌고 아무 일도 일어나지 않습니다. 주석을 풀고 ... 부분을 직접 채워 넣는 것이 과제입니다.
- 문제 1은
s22_batch_role 을 통해 orders/order_items 에만 SELECT/INSERT 를 주는 문제로, SET DEFAULT ROLE ALL TO 's22_batch'@'%'; 를 잊지 않는지 확인하는 것이 포인트입니다.
- 문제 2는 SQL 로 답이 나오지 않습니다. 힌트에 적힌 대로 셸에서
mysql ... -us22_batch -pBatch#123 shop -e "SELECT * FROM customers LIMIT 1;" 를 직접 실행해 에러 번호를 주석에 적어 넣는 문제입니다(테이블 자체에 권한이 없으므로 컬럼 거부 1143 이 아니라 테이블 거부 1142 입니다).
- 문제 3의 힌트가
products 의 전체 컬럼 목록(product_id, category_id, name, price, cost, stock, status, attrs, created_at)을 미리 알려 줍니다. 여기서 cost 하나만 빼고 나열하면 됩니다 — 컬럼 레벨 권한은 "제외" 문법이 없어서 허용할 컬럼을 전부 열거해야 한다는 점이 학습 포인트입니다.
- 문제 4는
FAILED_LOGIN_ATTEMPTS 5 + PASSWORD_LOCK_TIME 2 + PASSWORD EXPIRE 조합을, 문제 5는 롤에서 INSERT 만 골라 REVOKE 하는 것을 다룹니다.
- 문제 6은 반드시 실행하세요. 공용 서버에
s22_batch/s22_temp 계정과 롤이 남으면 다음 사람의 실습이 깨집니다.
-- =====================================================================
-- Step 22 — 사용자와 보안 : exercise.sql (연습문제)
-- 실행: mysql -h127.0.0.1 -P3307 -uroot -proot1234 shop -t --force < exercise.sql
-- ※ root 로 실행. 마지막 [문제 6] 에서 반드시 정리하세요.
-- =====================================================================
-- 깨끗한 시작
DROP ROLE IF EXISTS s22_batch_role, s22_prod_role;
DROP USER IF EXISTS 's22_batch'@'%', 's22_temp'@'%';
-- ---------------------------------------------------------------------
-- [문제 1] 배치 계정 s22_batch@'%' 를 만들고,
-- 롤 s22_batch_role 을 통해 orders/order_items 에만
-- SELECT/INSERT 권한을 부여하라. 기본 롤도 활성화하라.
-- ---------------------------------------------------------------------
-- TODO
-- CREATE USER 's22_batch'@'%' IDENTIFIED WITH caching_sha2_password BY 'Batch#123';
-- CREATE ROLE s22_batch_role;
-- GRANT ... ;
-- GRANT s22_batch_role TO 's22_batch'@'%';
-- SET DEFAULT ROLE ALL TO 's22_batch'@'%';
-- 확인
-- SHOW GRANTS FOR 's22_batch'@'%' USING s22_batch_role;
-- ---------------------------------------------------------------------
-- [문제 2] s22_batch 가 customers 를 SELECT 하면 어떤 에러가 나는가?
-- (셸에서 s22_batch 로 로그인해 확인. 에러 번호를 주석에 적어라)
-- 힌트: mysql -h127.0.0.1 -P3307 -us22_batch -pBatch#123 shop -e "SELECT * FROM customers LIMIT 1;"
-- ---------------------------------------------------------------------
-- 답: ERROR ____ (____) : ____
-- ---------------------------------------------------------------------
-- [문제 3] 롤 s22_prod_role 을 만들고, products 에서 cost(원가)를 제외한
-- 모든 컬럼만 읽는 컬럼 레벨 SELECT 권한을 부여하라.
-- 힌트: 컬럼: product_id, category_id, name, price, cost, stock, status, attrs, created_at
-- cost 만 빼고 GRANT SELECT (...) ON shop.products TO ...
-- ---------------------------------------------------------------------
-- TODO
-- CREATE ROLE s22_prod_role;
-- GRANT SELECT (...) ON shop.products TO s22_prod_role;
-- SHOW GRANTS FOR s22_prod_role;
-- ---------------------------------------------------------------------
-- [문제 4] s22_temp@'%' 를 만들되:
-- - 연속 실패 5회 시 2일 잠금
-- - 생성 즉시 비밀번호 만료(다음 로그인 때 변경 강제)
-- ---------------------------------------------------------------------
-- TODO
-- CREATE USER 's22_temp'@'%' IDENTIFIED BY 'Temp#123' ... ;
-- ALTER USER ... PASSWORD EXPIRE;
-- SELECT user, JSON_EXTRACT(User_attributes,'$.Password_locking') AS p, password_expired
-- FROM mysql.user WHERE user='s22_temp';
-- ---------------------------------------------------------------------
-- [문제 5] s22_batch_role 에서 INSERT 권한만 회수하고 남은 권한을 확인하라.
-- ---------------------------------------------------------------------
-- TODO
-- REVOKE INSERT ON ... FROM s22_batch_role;
-- SHOW GRANTS FOR s22_batch_role;
-- ---------------------------------------------------------------------
-- [문제 6] 실습에서 만든 계정/롤을 전부 삭제하라. (반드시 실행!)
-- ---------------------------------------------------------------------
-- TODO
-- DROP USER IF EXISTS ... ;
-- DROP ROLE IF EXISTS ... ;
solution.sql
exercise.sql 의 정답입니다. 먼저 스스로 풀어 본 뒤에 열어 보세요.
- 정답 1은
GRANT SELECT, INSERT ON shop.orders TO s22_batch_role; 처럼 테이블마다 한 줄씩 GRANT 합니다. shop.* 로 뭉뚱그리지 않는 것이 최소권한 원칙입니다.
- 정답 2는 주석으로만 적혀 있습니다:
ERROR 1142 (42000): SELECT command denied ... for table 'customers'. customers 에는 컬럼 권한조차 주지 않았기 때문에 컬럼 거부(1143)가 아니라 테이블 거부(1142) 가 나옵니다. 본문 22-3 의 s22_alice 사례(컬럼 권한이 있어 email 만 1143)와 비교해 보면 두 에러의 차이가 분명해집니다.
- 정답 3의
GRANT SELECT (product_id, category_id, name, price, stock, status, attrs, created_at) ON shop.products TO s22_prod_role; 에서 cost 만 빠져 있습니다. 이 롤을 가진 계정은 SELECT cost FROM products 나 SELECT * 를 하면 거부됩니다.
- 정답 5의
REVOKE INSERT ON shop.orders FROM s22_batch_role; 이 롤의 진가를 보여줍니다 — 롤에서 회수하면 그 롤을 가진 s22_batch 유저에게 즉시 반영되므로 유저마다 REVOKE 할 필요가 없습니다(본문 22-7 의 실무 팁).
- 정답 6이
DROP USER/DROP ROLE 후 leftover 개수를 세어 정리가 끝났는지 확인합니다.
-- =====================================================================
-- Step 22 — 사용자와 보안 : solution.sql (정답)
-- 실행: mysql -h127.0.0.1 -P3307 -uroot -proot1234 shop -t --force < solution.sql
-- =====================================================================
-- 깨끗한 시작
DROP ROLE IF EXISTS s22_batch_role, s22_prod_role;
DROP USER IF EXISTS 's22_batch'@'%', 's22_temp'@'%';
-- ---------------------------------------------------------------------
-- [정답 1] 배치 계정 + 롤
-- ---------------------------------------------------------------------
CREATE USER 's22_batch'@'%' IDENTIFIED WITH caching_sha2_password BY 'Batch#123';
CREATE ROLE s22_batch_role;
GRANT SELECT, INSERT ON shop.orders TO s22_batch_role;
GRANT SELECT, INSERT ON shop.order_items TO s22_batch_role;
GRANT s22_batch_role TO 's22_batch'@'%';
SET DEFAULT ROLE ALL TO 's22_batch'@'%';
SHOW GRANTS FOR 's22_batch'@'%' USING s22_batch_role;
-- GRANT USAGE ON *.* ...
-- GRANT SELECT, INSERT ON `shop`.`orders` ...
-- GRANT SELECT, INSERT ON `shop`.`order_items` ...
-- GRANT `s22_batch_role`@`%` ...
-- ---------------------------------------------------------------------
-- [정답 2] s22_batch 가 customers 를 읽으면:
-- ERROR 1142 (42000): SELECT command denied to user 's22_batch'@'...'
-- for table 'customers'
-- (customers 에는 아무 권한도 주지 않았으므로 테이블 레벨 거부)
-- ---------------------------------------------------------------------
-- ---------------------------------------------------------------------
-- [정답 3] cost 를 제외한 컬럼 레벨 SELECT
-- ---------------------------------------------------------------------
CREATE ROLE s22_prod_role;
GRANT SELECT (product_id, category_id, name, price, stock, status, attrs, created_at)
ON shop.products TO s22_prod_role; -- cost 만 빠졌다
SHOW GRANTS FOR s22_prod_role;
-- ---------------------------------------------------------------------
-- [정답 4] 실패 5회 → 2일 잠금 + 즉시 만료
-- ---------------------------------------------------------------------
CREATE USER 's22_temp'@'%' IDENTIFIED BY 'Temp#123'
FAILED_LOGIN_ATTEMPTS 5
PASSWORD_LOCK_TIME 2;
ALTER USER 's22_temp'@'%' PASSWORD EXPIRE;
SELECT user,
JSON_EXTRACT(User_attributes, '$.Password_locking') AS lock_policy,
password_expired
FROM mysql.user WHERE user = 's22_temp';
-- lock_policy: {"failed_login_attempts": 5, "password_lock_time_days": 2}
-- password_expired: Y
-- ---------------------------------------------------------------------
-- [정답 5] 롤에서 INSERT 만 회수
-- ---------------------------------------------------------------------
REVOKE INSERT ON shop.orders FROM s22_batch_role;
REVOKE INSERT ON shop.order_items FROM s22_batch_role;
SHOW GRANTS FOR s22_batch_role;
-- 이제 orders/order_items 에 SELECT 만 남는다.
-- ★ 롤에서 회수하면 s22_batch 유저에게도 즉시 반영된다(사람마다 REVOKE 불필요).
-- ---------------------------------------------------------------------
-- [정답 6] 정리
-- ---------------------------------------------------------------------
DROP USER IF EXISTS 's22_batch'@'%', 's22_temp'@'%';
DROP ROLE IF EXISTS s22_batch_role, s22_prod_role;
SELECT 'cleanup done' AS status,
(SELECT COUNT(*) FROM mysql.user WHERE user LIKE 's22%') AS leftover;