MSSQL 6.1 · 6부. 운영과 실전

권한과 보안

로그인·사용자·역할을 나누고 최소 권한만 줍니다.

예상 학습 시간 20분 난이도 중급
개념 설명

세 층으로 나뉩니다

SQL Server 의 권한은 서버와 데이터베이스가 따로입니다. 둘을 잇는 것이 사용자입니다.

무엇어디에하는 일
로그인(LOGIN)서버서버에 접속합니다
사용자(USER)데이터베이스그 데이터베이스 안에서의 신분입니다
역할(ROLE)데이터베이스권한을 묶어 두는 이름입니다

로그인만으로는 아무 데이터베이스에도 들어가지 못합니다. 들어갈 데이터베이스마다 사용자를 만들어 이어 주어야 합니다.

SQL
-- 서버에 로그인을 만듭니다.
CREATE LOGIN lab_app WITH PASSWORD = '…';
GO

-- 이 데이터베이스에 그 로그인의 사용자를 만듭니다.
CREATE USER app_user FOR LOGIN lab_app;
GO

SELECT name AS 이름, type_desc AS 종류, authentication_type_desc AS 인증
FROM sys.database_principals WHERE name = 'app_user';
결과
이름종류인증
app_userSQL_USERINSTANCE
인증이 INSTANCE 면 서버 로그인에 딸린 사용자입니다.

Windows 인증을 쓸 수 있으면 그쪽이 낫습니다. CREATE LOGIN [DOMAIN\사용자] FROM WINDOWS 로 만들면 암호를 연결 문자열에 적지 않아도 됩니다. 웹 서버라면 응용 프로그램 풀 계정으로 접속하게 두는 방법이 있습니다.

실측

주지 않으면 못 합니다

사용자를 만들기만 하면 접속만 되고 아무것도 하지 못합니다. EXECUTE AS USER 로 그 사용자인 척 실행해 확인해 봅니다.

SQL
EXECUTE AS USER = 'app_user';      -- 여기서부터 그 사용자입니다
SELECT COUNT(*) FROM Board.POSTS;
REVERT;                             -- 원래 신분으로 돌아옵니다
결과
메시지 229, 수준 14, 상태 5 The SELECT permission was denied on the object 'POSTS', database 'MssqlLab', schema 'Board'.
229 는 이 단원에서 계속 나옵니다. "그 권한이 없습니다" 라는 뜻입니다.

EXECUTE AS USER창을 두 개 열지 않고도 권한을 확인하는 방법입니다. 반드시 REVERT 로 돌아오십시오. 잊으면 그 세션이 계속 그 사용자로 남습니다.

SQL
GRANT SELECT ON Board.POSTS TO app_user;
GO

EXECUTE AS USER = 'app_user';
SELECT COUNT(*) FROM Board.POSTS;                -- 이제 됩니다
INSERT INTO Board.POSTS (…) VALUES (…);        -- 이것은요
REVERT;
결과
문장결과
SELECT507
INSERT229 — The INSERT permission was denied
권한은 동작마다 따로입니다. SELECT 를 주었다고 INSERT 가 되지 않습니다.
문장하는 일
GRANT허용합니다
REVOKE주었던 것을 거둡니다. 허용도 거부도 아닌 상태로 돌립니다
DENY막습니다. 다른 데서 허용해도 막힙니다
최소 권한

표를 열지 않고 프로시저만 엽니다

응용 프로그램 계정에 표 권한을 주지 마십시오. 프로시저 실행 권한만 주면 그 프로시저가 하는 일만 할 수 있습니다.

SQL
-- 표 권한은 거둡니다.
REVOKE SELECT ON Board.POSTS FROM app_user;
GO

CREATE OR ALTER PROCEDURE Board.P_POST_COUNT
    @board_num int
AS
BEGIN
    SET NOCOUNT ON;
    SELECT COUNT(*) AS 글수 FROM Board.POSTS WHERE board_num = @board_num;
END
GO

GRANT EXECUTE ON Board.P_POST_COUNT TO app_user;
GO

EXECUTE AS USER = 'app_user';
SELECT COUNT(*) FROM Board.POSTS;               -- 직접 조회
EXEC Board.P_POST_COUNT @board_num = 1;      -- 프로시저로
REVERT;
결과
방법결과
표를 직접 조회229 — 거부
프로시저로35
표 권한이 없는데도 프로시저 안에서는 읽힙니다.

소유권 체인이라고 합니다. 프로시저와 표의 소유자가 같으면 안쪽 표의 권한을 다시 따지지 않습니다. 둘 다 Board 스키마에 있고 그 스키마의 소유자가 같기 때문입니다.

이것이 최소 권한을 실제로 하는 방법입니다. 응용 프로그램은 정해진 프로시저만 부를 수 있고, 표를 통째로 지우거나 다른 사람의 글을 고칠 수 없습니다. 연결 문자열이 새어 나가도 피해가 그 프로시저들이 하는 일로 한정됩니다.

동적 SQL 은 체인을 끊습니다

SQL
CREATE OR ALTER PROCEDURE Board.P_POST_COUNT_DYN
    @board_num int
AS
BEGIN
    SET NOCOUNT ON;
    DECLARE @sql nvarchar(200) =
        N'SELECT COUNT(*) AS 글수 FROM Board.POSTS WHERE board_num = @b';
    EXEC sp_executesql @sql, N'@b int', @b = @board_num;
END
GO
GRANT EXECUTE ON Board.P_POST_COUNT_DYN TO app_user;
결과
메시지 229 - The SELECT permission was denied on the object 'POSTS'.
같은 일을 하는데 동적 SQL 로 적었다는 것만 다릅니다.

동적 SQL 은 별개의 배치로 실행되어 소유권 체인을 잇지 못합니다. 그래서 그 안의 표 권한을 사용자가 직접 가지고 있어야 합니다. 그러면 최소 권한이 무너집니다 — 표 권한을 주는 순간 프로시저를 거치지 않고도 무엇이든 할 수 있게 됩니다.

동적 SQL 이 꼭 필요하다면 WITH EXECUTE AS OWNER 를 붙입니다.

SQL
CREATE OR ALTER PROCEDURE Board.P_POST_COUNT_DYN2
    @board_num int
WITH EXECUTE AS OWNER            -- 소유자의 권한으로 실행합니다
AS
BEGINEND
결과
프로시저결과
동적 SQL 그대로229
WITH EXECUTE AS OWNER 를 붙임35
되기는 합니다. 다만 그 프로시저 안에서는 소유자의 모든 권한이 살아 있습니다.

EXECUTE AS OWNER 는 함부로 쓰지 마십시오. 그 프로시저 안에서는 소유자가 할 수 있는 모든 일이 열립니다. 동적 SQL 에 검색어를 이어 붙이는 곳이라면 주입 하나로 데이터베이스 전체가 넘어갑니다(4.9). 동적 SQL 을 쓰지 않는 것이 먼저입니다.

역할

사람마다 주지 말고 묶어 둡니다

사람이 늘 때마다 권한을 하나씩 주면 누가 무엇을 할 수 있는지 아무도 모르게 됩니다. 역할을 만들어 거기에 주고, 사람은 역할에 넣습니다.

SQL
CREATE ROLE app_reader;
GRANT SELECT ON SCHEMA::Board TO app_reader;   -- 스키마 통째로
ALTER ROLE app_reader ADD MEMBER app_user;
GO

EXECUTE AS USER = 'app_user';
SELECT COUNT(*) FROM Board.POSTS;
REVERT;
결과
507
사용자에게 직접 준 것은 없습니다. 역할이 가진 권한이 따라옵니다.

스키마 단위로 주면 나중에 만드는 표에도 자동으로 붙습니다.

SQL
-- 권한을 준 뒤에 표를 새로 만듭니다.
CREATE TABLE Board.T_NEW (num int IDENTITY PRIMARY KEY, memo nvarchar(50));
INSERT INTO Board.T_NEW (memo) VALUES (N'나중에 만든 표');
GO

EXECUTE AS USER = 'app_user';
SELECT COUNT(*) FROM Board.T_NEW;
REVERT;
결과
1
표마다 GRANT 를 다시 하지 않아도 됩니다. 3.7 에서 스키마로 나눈 값이 여기서 돌아옵니다.

DENY 는 GRANT 를 이깁니다

SQL
-- 역할로는 스키마 전체를 읽을 수 있는 상태입니다.
DENY SELECT ON Board.POSTS TO app_user;
GO

EXECUTE AS USER = 'app_user';
SELECT COUNT(*) FROM Board.POSTS;      -- 막은 표
SELECT COUNT(*) FROM Board.COMMENTS;   -- 같은 스키마의 다른 표
REVERT;
결과
결과
Board.POSTS229 — 거부
Board.COMMENTS1,013
스키마 전체에 GRANT 가 있어도 표 하나에 건 DENY 가 이깁니다.

DENY 는 되도록 쓰지 마십시오. "거의 다 되는데 이것만 안 되는" 상태를 만들면 왜 안 되는지 찾기가 어렵습니다. 필요한 것만 GRANT 하는 쪽이 읽기도 쉽고 사고도 적습니다.

고정 역할은 너무 넓습니다

고정 역할주는 것응용 프로그램 계정에
db_owner그 데이터베이스의 모든 것주지 마십시오
db_datareader모든 표 읽기되도록 피하십시오
db_datawriter모든 표 쓰기되도록 피하십시오
db_ddladmin표·프로시저를 만들고 지우기배포 계정에만

db_datareader 는 앞으로 생길 표까지 포함합니다. 나중에 누군가 개인 정보를 담은 표를 만들면 그것도 자동으로 읽힙니다. 스키마를 나누고 필요한 스키마에만 주는 편이 낫습니다(3.7).

점검

누가 무엇을 할 수 있습니까

SQL
-- 준 것을 목록으로 봅니다.
SELECT USER_NAME(p.grantee_principal_id) AS 받는이,
       p.state_desc AS 상태, p.permission_name AS 권한,
       CASE p.class
            WHEN 0 THEN N'데이터베이스'
            WHEN 1 THEN OBJECT_SCHEMA_NAME(p.major_id) + N'.' + OBJECT_NAME(p.major_id)
            WHEN 3 THEN N'스키마 ' + SCHEMA_NAME(p.major_id)
       END AS 대상
FROM sys.database_permissions p
WHERE p.grantee_principal_id > 4
ORDER BY 받는이, 대상;
결과
받는이상태권한대상
app_readerGRANTSELECT스키마 Board
app_userGRANTCONNECT데이터베이스
app_userGRANTEXECUTEBoard.P_POST_COUNT
app_userDENYSELECTBoard.POSTS
준 것은 보이지만 "그래서 무엇을 할 수 있는지" 는 다릅니다. 역할로 받은 것이 섞이기 때문입니다.

실제로 무엇이 되는지는 그 사용자로 바꿔 물어봐야 합니다.

SQL
EXECUTE AS USER = 'app_user';

SELECT 'POSTS' AS 표, permission_name AS 권한
FROM fn_my_permissions('Board.POSTS', 'OBJECT')
WHERE subentity_name = ''                -- 열 단위 권한은 뺍니다
  AND permission_name IN ('SELECT','INSERT','UPDATE','DELETE')
UNION ALL
SELECT 'COMMENTS', permission_name
FROM fn_my_permissions('Board.COMMENTS', 'OBJECT')
WHERE subentity_name = ''
  AND permission_name IN ('SELECT','INSERT','UPDATE','DELETE');

REVERT;
결과
권한
COMMENTSSELECT
POSTS 는 한 줄도 나오지 않습니다. DENY 가 살아 있기 때문입니다.
연습

직접 해보기

1. 게시판 응용 프로그램 계정을 만듭니다 난이도 하

게시판 웹 사이트가 사용할 계정을 만듭니다. 글을 읽고 쓰되 표에는 직접 접근하지 못하게 하십시오. 회원 표는 아예 보이지 않아야 합니다.

-- 1. 로그인과 사용자 CREATE LOGIN board_app WITH PASSWORD = '…'; GO CREATE USER board_user FOR LOGIN board_app; GO -- 2. 역할을 만들고 프로시저 실행 권한만 담습니다. CREATE ROLE board_executor; GRANT EXECUTE ON SCHEMA::Board TO board_executor; -- 스키마 단위로 주면 앞으로 만드는 프로시저에도 붙습니다. ALTER ROLE board_executor ADD MEMBER board_user; GO -- 3. 확인합니다. EXECUTE AS USER = 'board_user'; SELECT COUNT(*) FROM Board.POSTS; -- 229, 표는 못 봅니다 EXEC Board.P_POST_COUNT @board_num = 1; -- 35, 프로시저는 됩니다 SELECT COUNT(*) FROM Member.USERS; -- 229, 회원 표도 못 봅니다 REVERT; GO -- 회원 표를 따로 막을 필요가 없습니다. -- 애초에 아무것도 주지 않았으므로 Member 스키마 전체가 닫혀 있습니다. -- 3.7 에서 스키마를 나눠 둔 값이 여기서 돌아옵니다. -- 회원 정보가 필요한 프로시저는 Board 스키마에 두고 -- 그 안에서 Member 표를 읽습니다. 소유권 체인으로 통과합니다. -- 그러면 "무엇을 볼 수 있는가" 가 프로시저 목록으로 정해집니다.
2. 이 계정으로 무엇까지 할 수 있습니까 난이도 중

인수인계받은 시스템의 연결 문자열에 User ID=sa 가 적혀 있습니다. 무엇이 문제이고 어떤 순서로 고치겠습니까. 서비스를 멈추지 않고 바꿔야 합니다.

새 계정을 만들어 놓고 무엇이 필요한지 먼저 알아내야 합니다. 응용 프로그램이 실제로 무슨 문장을 보내는지 볼 방법이 있습니다.
-- 무엇이 문제인가 -- sa 는 서버 전체의 관리자입니다. -- 연결 문자열이 새면 모든 데이터베이스가 넘어갑니다. -- SQL 주입 하나로 다른 데이터베이스까지 지울 수 있습니다(4.9). -- 누가 무엇을 했는지 기록에서 구분할 수도 없습니다. -- 1. 응용 프로그램이 실제로 무엇을 부르는지 모읍니다. SELECT DISTINCT OBJECT_SCHEMA_NAME(qt.objectid, qt.dbid) AS 스키마, OBJECT_NAME(qt.objectid, qt.dbid) AS 프로시저, LEFT(qt.text, 80) AS 문장 FROM sys.dm_exec_cached_plans p CROSS APPLY sys.dm_exec_sql_text(p.plan_handle) qt WHERE qt.dbid = DB_ID(); -- 며칠 두고 모아야 합니다. 캐시는 언제든 비워집니다. -- 확실히 하려면 확장 이벤트로 며칠 기록하십시오. -- 2. 새 계정을 만들고 그만큼만 줍니다. CREATE LOGIN board_app WITH PASSWORD = '…'; CREATE USER board_user FOR LOGIN board_app; CREATE ROLE board_executor; GRANT EXECUTE ON SCHEMA::Board TO board_executor; ALTER ROLE board_executor ADD MEMBER board_user; -- 3. 시험 환경에서 새 계정으로 전부 돌려 봅니다. -- 화면을 하나씩 눌러 229 가 나오는 자리를 찾습니다. -- 빠진 권한이 나오면 그때 하나씩 더합니다. -- 4. 운영에 반영합니다. 연결 문자열만 바꾸면 되므로 멈출 일이 없습니다. -- 문제가 생기면 되돌리기도 연결 문자열 하나입니다. -- 5. 며칠 지켜본 뒤 sa 를 잠급니다. ALTER LOGIN sa DISABLE; -- 지우지는 못합니다. 잠그는 것이 최선입니다. -- 잠그기 전에 다른 관리 계정이 있는지 반드시 확인하십시오. -- 함께 볼 것 -- 응용 프로그램마다 계정을 따로 두면 기록에서 구분됩니다. -- 배포용 계정(db_ddladmin)과 운영용 계정을 나누십시오. -- 운영 계정에는 표를 고칠 권한이 없어야 합니다(6.3).

실습에서 만든 것을 지웁니다.
DROP PROCEDURE Board.P_POST_COUNT, Board.P_POST_COUNT_DYN, Board.P_POST_COUNT_DYN2;
DROP TABLE Board.T_NEW; ALTER ROLE app_reader DROP MEMBER app_user;
DROP ROLE app_reader; DROP USER app_user; DROP LOGIN lab_app;

요약
  • 권한은 서버(로그인)와 데이터베이스(사용자)가 따로입니다. 로그인만으로는 어느 데이터베이스에도 들어가지 못합니다.
  • 사용자를 만들기만 하면 접속만 되고 아무것도 못 합니다. 권한이 없으면 229 입니다.
  • 권한은 동작마다 따로입니다. SELECT 를 주었다고 INSERT 가 되지 않습니다.
  • EXECUTE AS USER그 사용자인 척 실행해 확인할 수 있습니다. REVERT 를 잊지 마십시오.
  • 표 권한을 주지 말고 프로시저 실행 권한만 주십시오. 소유자가 같으면 프로시저 안의 표 권한을 다시 따지지 않습니다(소유권 체인).
  • 동적 SQL 은 체인을 끊습니다(229). WITH EXECUTE AS OWNER 로 넘길 수는 있지만 그 안에서 소유자의 모든 권한이 열리므로 동적 SQL 을 쓰지 않는 편이 먼저입니다(4.9).
  • 사람마다 주지 말고 역할에 주고 사람을 역할에 넣으십시오. GRANT … ON SCHEMA:: 로 주면 나중에 만드는 것에도 붙습니다.
  • DENYGRANT 를 이깁니다. 다만 왜 안 되는지 찾기 어려워지므로 필요한 것만 GRANT 하는 편이 낫습니다.
  • db_owner · db_datareader 같은 고정 역할은 앞으로 생길 것까지 포함하므로 응용 프로그램 계정에 주지 마십시오.
  • 준 것은 sys.database_permissions, 실제로 되는 것fn_my_permissions 로 봅니다. 역할로 받은 것이 섞이므로 둘이 다릅니다.