РСализация Ρ€ΠΎΠ»Π΅Π²ΠΎΠΉ ΠΌΠΎΠ΄Π΅Π»ΠΈ доступа с использованиСм Row Level Security Π² PostgreSQL

Π Π°Π·Π²ΠΈΡ‚ΠΈΠ΅ Ρ‚Π΅ΠΌΡ‹ Π­Ρ‚ΡŽΠ΄ ΠΏΠΎ Ρ€Π΅Π°Π»ΠΈΠ·Π°Ρ†ΠΈΠΈ Row Level Secutity Π² PostgreSQL ΠΈ для Ρ€Π°Π·Π²Π΅Ρ€Π½ΡƒΡ‚ΠΎΠ³ΠΎ ΠΎΡ‚Π²Π΅Ρ‚Π° Π½Π° ΠΊΠΎΠΌΠΌΠ΅Π½Ρ‚Π°Ρ€ΠΈΠΉ.

Использованная стратСгия ΠΏΠΎΠ΄Ρ€Π°Π·ΡƒΠΌΠ΅Π²Π°Π΅Ρ‚ использованиС ΠΊΠΎΠ½Ρ†Π΅ΠΏΡ†ΠΈΠΈ «БизнСс-Π»ΠΎΠ³ΠΈΠΊΠ° Π² Π‘Π”Β», Ρ‡Ρ‚ΠΎ Π±Ρ‹Π»ΠΎ Ρ‡ΡƒΡ‚ΡŒ ΠΏΠΎΠ΄Ρ€ΠΎΠ±Π½Π΅Π΅ описано здСсь β€” Π­Ρ‚ΡŽΠ΄ ΠΏΠΎ рСализация бизнСс-Π»ΠΎΠ³ΠΈΠΊΠΈ Π½Π° ΡƒΡ€ΠΎΠ²Π½Π΅ Ρ…Ρ€Π°Π½ΠΈΠΌΡ‹Ρ… Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΉ PostgreSQL

ВСорСтичСская Ρ‡Π°ΡΡ‚ΡŒ ΠΎΡ‚Π»ΠΈΡ‡Π½ΠΎ описана Π² Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Π°Ρ†ΠΈΠΈ Postgres Pro β€” ΠŸΠΎΠ»ΠΈΡ‚ΠΈΠΊΠΈ Π·Π°Ρ‰ΠΈΡ‚Ρ‹ строк. НиТС рассмотрСна практичСская рСализация ΠΊΠΎΠ½ΠΊΡ€Π΅Ρ‚Π½ΠΎΠΉ бизнСс Π·Π°Π΄Π°Ρ‡ΠΈ β€” ролСвая модСль доступа ΠΊ Π΄Π°Π½Π½Ρ‹ΠΌ.

РСализация Ρ€ΠΎΠ»Π΅Π²ΠΎΠΉ ΠΌΠΎΠ΄Π΅Π»ΠΈ доступа с использованиСм Row Level Security Π² PostgreSQL

Π’ ΡΡ‚Π°Ρ‚ΡŒΠ΅ Π½ΠΈΡ‡Π΅Π³ΠΎ Π½ΠΎΠ²ΠΎΠ³ΠΎ, Π½Π΅Ρ‚ скрытого смысла ΠΈ Ρ‚Π°ΠΉΠ½Ρ‹Ρ… Π·Π½Π°Π½ΠΈΠΉ. ΠŸΡ€ΠΎΡΡ‚ΠΎ зарисовка ΠΎ практичСской Ρ€Π΅Π°Π»ΠΈΠ·Π°Ρ†ΠΈΠΈ тСорСтичСской ΠΈΠ΄Π΅ΠΈ. Если ΠΊΠΎΠΌΡƒ интСрСсно β€” Ρ‡ΠΈΡ‚Π°ΠΉΡ‚Π΅. ΠšΠΎΠΌΡƒ Π½Π΅ интСрСсно β€” Π½Π΅ Ρ‚Ρ€Π°Ρ‚ΡŒΡ‚Π΅ своС врСмя зря.

ΠŸΠΎΡΡ‚Π°Π½ΠΎΠ²ΠΊΠ° Π·Π°Π΄Π°Ρ‡ΠΈ

НСобходимо Ρ€Π°Π·Π³Ρ€Π°Π½ΠΈΡ‡ΠΈΡ‚ΡŒ доступ Π½Π° просмотр/вставку/ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠ΅/ΡƒΠ΄Π°Π»Π΅Π½ΠΈΠ΅ Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Π° Π² соотвСтствии с Ρ€ΠΎΠ»ΡŒΡŽ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ прилоТСния. Под Ρ€ΠΎΠ»ΡŒΡŽ подразумСваСтся запись Π² Ρ‚Π°Π±Π»ΠΈΡ†Π΅ roles связанной ΠΎΡ‚Π½ΠΎΡˆΠ΅Π½ΠΈΠ΅ΠΌ ΠΌΠ½ΠΎΠ³ΠΈΠ΅-ΠΊΠΎ-ΠΌΠ½ΠΎΠ³ΠΈΠΌ с Ρ‚Π°Π±Π»ΠΈΡ†Π΅ΠΉ users. Π”Π΅Ρ‚Π°Π»ΠΈ Ρ€Π΅Π°Π»ΠΈΠ·Π°Ρ†ΠΈΠΈ Ρ‚Π°Π±Π»ΠΈΡ†, ΠΏΠΎ ΠΏΡ€ΠΈΡ‡ΠΈΠ½Π΅ Ρ‚Ρ€ΠΈΠ²ΠΈΠ°Π»ΡŒΠ½ΠΎΡΡ‚ΠΈ, ΠΎΠΏΡƒΡ‰Π΅Π½Ρ‹. Π’Π°ΠΊΠΆΠ΅ ΠΎΠΏΡƒΡ‰Π΅Π½Ρ‹ ΠΊΠΎΠ½ΠΊΡ€Π΅Ρ‚Π½Ρ‹Π΅ Π΄Π΅Ρ‚Π°Π»ΠΈ Ρ€Π΅Π°Π»ΠΈΠ·Π°Ρ†ΠΈΠΈ связанныС с ΠΏΡ€Π΅Π΄ΠΌΠ΅Ρ‚Π½ΠΎΠΉ ΠΎΠ±Π»Π°ΡΡ‚ΡŒΡŽ.

РСализация

Π‘ΠΎΠ·Π΄Π°Π΅ΠΌ Ρ€ΠΎΠ»ΠΈ, схСмы, Ρ‚Π°Π±Π»ΠΈΡ†Ρƒ

Π‘ΠΎΠ·Π΄Π°Π½ΠΈΠ΅ ΠΎΠ±ΡŠΠ΅ΠΊΡ‚ΠΎΠ² Π‘Π”

CREATE ROLE store;
CREATE SCHEMA store AUTHORIZATION store;
CREATE TABLE store.docs
(
  id integer ,         --id Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Π°
  man_id integer , --id ΠΌΠ΅Π½Π΅Π΄ΠΆΠ΅Ρ€Π° Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Π°
  stat_id integer ,  --id статуса Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Π°
  ...
  is_del BOOLEAN DEFAULT FALSE 
);
ALTER TABLE store.docs ADD CONSTRAINT doc_pk PRIMARY KEY (id);
ALTER TABLE store.docs OWNER TO store ;

Π‘ΠΎΠ·Π΄Π°Π΅ΠΌ Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΈ для Ρ€Π΅Π°Π»ΠΈΠ·Π°Ρ†ΠΈΠΈ RLS

ΠŸΡ€ΠΎΠ²Π΅Ρ€ΠΊΠ° возмоТности Π²Ρ‹ΠΏΠΎΠ»Π½ΠΈΡ‚ΡŒ SELECT строки

check_select

CREATE OR REPLACE FUNCTION store.check_select ( current_id store.docs.id%TYPE ) RETURNS boolean AS $$
DECLARE
  result boolean ;
  curr_pid integer ;
  curr_stat_id integer ;
  doc_man_id integer ;
BEGIN 
  -- DBA ΠΈΠΌΠ΅Π΅Ρ‚ доступ ΠΊΠΎ всСм Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Π°ΠΌ
  IF SESSION_USER = 'curr_dba'
  THEN
    RETURN TRUE ;
  END IF ;
  --------------------------------

  --Если Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚ ΠΈΠΌΠ΅Π΅Ρ‚ ΠΌΠ΅Ρ‚ΠΊΡƒ 'ΡƒΠ΄Π°Π»Π΅Π½' - Π½Π΅ ΠΏΠΎΠΊΠ°Π·Ρ‹Π²Π°Ρ‚ΡŒ Π² Π²Ρ‹Π±ΠΎΡ€ΠΊΠ΅
  SELECT
    is_del
  INTO
    result
  FROM
    store.docs
  WHERE
    id = current_id ;
 IF result = TRUE
 THEN
   RETURN FALSE ;
 END IF ;
 --------------------------------

 --ΠŸΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ id Ρ‚Π΅ΠΊΡƒΡ‰Π΅Π³ΠΎ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ
 SELECT
   service_function.get_curr_pid ()
 INTO
   curr_pid ;
 --------------------------------

 --ΠŸΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ id ΠΌΠ΅Π½Π΅Π΄ΠΆΠ΅Ρ€Π° Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Π°
 SELECT
   man_id
 INTO
   doc_man_id
 FROM
   store.docs
 WHERE
   id = current_id ;
 --------------------------------

 --Если ΠΌΠ΅Π½Π΅Π΄ΠΆΠ΅Ρ€ Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Π° Π½Π΅ Ρ‚Π΅ΠΊΡƒΡ‰ΠΈΠΉ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒ ΠΈΠ»ΠΈ ΠΌΠ΅Π½Π΅Π΄ΠΆΠ΅Ρ€ Π½Π΅ Π½Π°Π·Π½Π°Ρ‡Π΅Π½
 --Π΄ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚ Π² Π²Ρ‹Π±ΠΎΡ€ΠΊΡƒ
 IF doc_man_id != curr_pid OR doc_man_id IS NULL
 THEN
   RETURN TRUE  ;
 ELSE
   --ΠŸΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ Ρ‚Π΅ΠΊΡƒΡ‰ΠΈΠΉ статус Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Π°
   SELECT
     stat_id                                         
   INTO
     curr_statid
   FROM
     store.docs
   WHERE
     id = current_id ;
    
   --Если статус позволяСт ΠΏΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚ - Π΄ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚ Π² Π²Ρ‹Π±ΠΎΡ€ΠΊΡƒ                     
   IF curr_statid = 4 OR curr_statid = 9
   THEN
     RETURN TRUE ;
   ELSE
   --Π˜Π½Π°Ρ‡Π΅ - ΠΈΡΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚ ΠΈΠ· Π²Ρ‹Π±ΠΎΡ€ΠΊΠΈ
     RETURN FALSE ;
    END IF ;
  END IF ;
  --------------------------------

 RETURN FALSE ;
END
$$ LANGUAGE plpgsql SECURITY DEFINER;
ALTER FUNCTION store.check_select( store.docs.id%TYPE  ) OWNER TO store ;
REVOKE EXECUTE ON FUNCTION store.check_select( store.docs.id%TYPE  ) FROM public; 
GRANT EXECUTE ON FUNCTION store.check_select( store.docs.id%TYPE  ) TO service_functions; 

ΠŸΡ€ΠΎΠ²Π΅Ρ€ΠΊΠ° возмоТности Π²Ρ‹ΠΏΠΎΠ»Π½ΠΈΡ‚ΡŒ INSERT строки

check_insert

CREATE OR REPLACE FUNCTION store.check_insert ( current_id store.docs.id%TYPE ) RETURNS boolean AS $$
DECLARE
  curr_role_id integer ;
BEGIN
  --DBA ΠΌΠΎΠΆΠ΅Ρ‚ Π΄ΠΎΠ±Π°Π²Π»ΡΡ‚ΡŒ строку Π² любом случаС
  IF SESSION_USER = 'curr_dba'
  THEN
    RETURN TRUE ;
  END IF ;
  --------------------------------

 --ΠŸΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ id Ρ€ΠΎΠ»ΠΈ Ρ‚Π΅ΠΊΡƒΡ‰Π΅Π³ΠΎ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ 
 SELECT
   service_functions.current_rid()
  INTO
    curr_role_id ;
 --------------------------------

--Если Ρ€ΠΎΠ»ΡŒ допускаСт Π²ΠΎΠ·ΠΌΠΎΠΆΠ½ΠΎΡΡ‚ΡŒ создания Π½ΠΎΠ²ΠΎΠ³ΠΎ Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Π°
--Ρ€Π°Π·Ρ€Π΅ΡˆΠΈΡ‚ΡŒ
IF curr_role_id = 3 OR curr_role_id = 5     
THEN
  RETURN TRUE ;
END IF ;
--------------------------------
RETURN FALSE  ;
END
$$ LANGUAGE plpgsql SECURITY DEFINER;
ALTER FUNCTION store.check_insert( store.docs.id%TYPE  ) OWNER TO store ;
REVOKE EXECUTE ON FUNCTION store.check_insert( store.docs.id%TYPE  ) FROM public;
GRANT EXECUTE ON FUNCTION store.check_insert( store.docs.id%TYPE  ) TO service_functions; 

ΠŸΡ€ΠΎΠ²Π΅Ρ€ΠΊΠ° возмоТности Π²Ρ‹ΠΏΠΎΠ»Π½ΠΈΡ‚ΡŒ DELETE строки

check_delete

CREATE OR REPLACE FUNCTION store.check_delete ( current_id store.docs.id%TYPE )
RETURNS boolean AS $$
BEGIN  
  --Волько DBA ΠΌΠΎΠΆΠ΅Ρ‚ ΡƒΠ΄Π°Π»ΡΡ‚ΡŒ строку 
  IF SESSION_USER = 'curr_dba'
  THEN
    RETURN TRUE ;
  END IF ;
  --------------------------------

  RETURN FALSE ;
END
$$ LANGUAGE plpgsql
SECURITY DEFINER;
ALTER FUNCTION store.check_delete( store.docs.id%TYPE  ) OWNER TO store ;
REVOKE EXECUTE ON FUNCTION store.check_delete( store.docs.id%TYPE  ) FROM public;

ΠŸΡ€ΠΎΠ²Π΅Ρ€ΠΊΠ° возмоТности Π²Ρ‹ΠΏΠΎΠ»Π½ΠΈΡ‚ΡŒ UPDATE строки.

update_using

CREATE OR REPLACE FUNCTION store.update_using ( current_id store.docs.id%TYPE , is_del boolean  )
RETURNS boolean AS $$
BEGIN  
   --Π”ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Ρ‹ ΠΈΠΌΠ΅ΡŽΡ‰ΠΈΠ΅ статус 'ΡƒΠ΄Π°Π»Π΅Π½' - Π½Π΅ Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΈΡ€ΡƒΡŽΡ‚ΡΡ
   IF is_del 
   THEN
     RETURN FALSE ;
 ELSE
    RETURN TRUE ;
  END IF ;

END
$$ LANGUAGE plpgsql SECURITY DEFINER;
ALTER FUNCTION store.update_using(  store.docs.id%TYPE ,  boolean  ) OWNER TO store ;
REVOKE EXECUTE ON FUNCTION store.update_using(  store.docs.id%TYPE ,  boolean  ) FROM public;
GRANT EXECUTE ON FUNCTION store.update_using( store.docs.id%TYPE  ) TO service_functions;

update_check

CREATE OR REPLACE FUNCTION store.update_with_check ( current_id store.docs.id%TYPE , is_del boolean )
RETURNS boolean AS $$
DECLARE
  current_rid integer ;
  current_statid integer ;
BEGIN                

  --DBA ΠΌΠΎΠΆΠ΅Ρ‚ ΠΏΡ€ΠΎΡΠΌΠ°Ρ‚Ρ€ΠΈΠ²Π°Ρ‚ΡŒ строку 
  IF SESSION_USER = 'curr_dba'
  THEN
    RETURN TRUE ;
  END IF ;
  --------------------------------

 --ΠŸΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ id Ρ€ΠΎΠ»ΠΈ Ρ‚Π΅ΠΊΡƒΡ‰Π΅Π³ΠΎ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ 
 SELECT
   service_functions.current_rid()
  INTO
    curr_role_id ;
 --------------------------------                            

 --Π£Π΄Π°Π»Π΅Π½ΠΈΠ΅ Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Π° - ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠ΅ ΠΏΡ€ΠΈΠ·Π½Π°ΠΊΠ° 
 IF is_deleted
 THEN
   --Если Ρ€ΠΎΠ»ΡŒ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ ***
   IF current_role_id = 3        
   THEN
      SELECT
        stat_id                                          
      INTO
        curr_statid
      FROM
        store.docs
      WHERE
        id = current_id ;

      --Π”ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚ Π² статусС *** нСльзя ΡƒΠ΄Π°Π»ΠΈΡ‚ΡŒ 
      IF current_status_id = 11
      THEN
         RETURN FALSE ;
      ELSE
      --МоТно ΡƒΠ΄Π°Π»ΠΈΡ‚ΡŒ Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚ Π² Π΄Ρ€ΡƒΠ³ΠΈΡ… статусах
        RETURN TRUE ;
      END IF ;

    --Π˜Π½Π°Ρ‡Π΅ , Ссли Ρ€ΠΎΠ»ΡŒ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ ***
    ELSIF current_role_id = 5            
    THEN
      --ВсС статусы Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Π° 
      RETURN TRUE ;
    ELSE
      --Π”Ρ€ΡƒΠ³ΠΈΠ΅ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΠΈ Π½Π΅ ΠΌΠΎΠ³ΡƒΡ‚ ΡƒΠ΄Π°Π»ΡΡ‚ΡŒ Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Ρ‹
      RETURN FALSE ;
    END IF ;
 ELSE      
   --ОбновлСниС Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Π° Ρ€Π°Π·Ρ€Π΅ΡˆΠ΅Π½ΠΎ
    RETURN TRUE ;
END IF ;

RETURN FALSE ;
END
$$ LANGUAGE plpgsql SECURITY DEFINER;
ALTER FUNCTION store.update_with_check( storg.docs.id%TYPE ,  boolean   ) OWNER TO store ;
REVOKE EXECUTE ON FUNCTION store.update_with_check( storg.docs.id%TYPE ,  boolean   )  FROM public;
GRANT EXECUTE ON FUNCTION store.update_with_check( store.docs.id%TYPE  ) TO service_functions;

Π’ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΠ΅ ΠΏΠΎΠ»ΠΈΡ‚ΠΈΠΊΠΈ Row Level Secutiry для Ρ‚Π°Π±Π»ΠΈΡ†Ρ‹.

ENABLE ROW LEVEL SECURITY

ALTER TABLE store.docs ENABLE ROW LEVEL SECURITY ;

CREATE POLICY doc_select ON store.docs FOR SELECT TO service_functions USING ( (SELECT store.check_select(id)) );
CREATE POLICY doc_insert ON store.docs FOR INSERT TO service_functions WITH CHECK ( (SELECT store.check_insert(id)) );
CREATE POLICY docs_delete ON store.docs FOR DELETE TO service_functions USING ( (SELECT store.check_delete(id)) );

CREATE POLICY doc_update_using ON store.docs FOR UPDATE TO service_functions USING ( (SELECT store.update_using(id , is_del )) );
CREATE POLICY doc_update_check ON store.docs FOR UPDATE TO service_functions  WITH CHECK ( (SELECT store.update_with_check(id , is_del )) );

Π˜Ρ‚ΠΎΠ³

Π­Ρ‚ΠΎ Ρ€Π°Π±ΠΎΡ‚Π°Π΅Ρ‚.

ΠŸΡ€Π΅Π΄Π»ΠΎΠΆΠ΅Π½Π½Π°Ρ стратСгия ΠΏΠΎΠ·Π²ΠΎΠ»ΠΈΠ»Π° пСрСнСсти Ρ€Π΅Π°Π»ΠΈΠ·Π°Ρ†ΠΈΡŽ Ρ€ΠΎΠ»Π΅Π²ΠΎΠΉ ΠΌΠΎΠ΄Π΅Π»ΠΈ с уровня бизнСс-Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΉ Π½Π° ΡƒΡ€ΠΎΠ²Π΅Π½ΡŒ хранСния Π΄Π°Π½Π½Ρ‹Ρ….

Π€ΡƒΠ½ΠΊΡ†ΠΈΠΈ ΠΌΠΎΠ³ΡƒΡ‚ Π±Ρ‹Ρ‚ΡŒ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Π½Ρ‹ Π² качСствС шаблона для Ρ€Π΅Π°Π»ΠΈΠ·Π°Ρ†ΠΈΠΈ Π±ΠΎΠ»Π΅Π΅ ΠΈΠ·ΠΎΡ‰Ρ€Π΅Π½Π½Ρ‹Ρ… ΠΌΠΎΠ΄Π΅Π»Π΅ΠΉ скрытия Π΄Π°Π½Π½Ρ‹Ρ…, Ссли Ρ‚ΠΎΠ³ΠΎ Ρ‚Ρ€Π΅Π±ΡƒΡŽΡ‚ бизнСс-трСбования.

Π˜ΡΡ‚ΠΎΡ‡Π½ΠΈΠΊ: habr.com