feat: publish application permissions to DDS grants
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
- 기본 포트: `8083`
|
- 기본 포트: `8083`
|
||||||
- 기본 화면: `/` (공통 권한 여정), `/dds` (DDS 직접 조회 검증)
|
- 기본 화면: `/` (공통 권한 여정), `/dds` (DDS 직접 조회 검증)
|
||||||
|
- 권한 반영: `/dds-provision` (공통 권한을 DDS DATA GRANT로 미리보기·게시)
|
||||||
- 기본 보호 객체: `ADMIN.V_DDS_CUSTOMERS_PG`, `ADMIN.V_DDS_CUSTOMERS_MY`
|
- 기본 보호 객체: `ADMIN.V_DDS_CUSTOMERS_PG`, `ADMIN.V_DDS_CUSTOMERS_MY`
|
||||||
- 지식 검색 보호 객체: `ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS`
|
- 지식 검색 보호 객체: `ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS`
|
||||||
- DDS 전용 사용자: `dds_demo_my`, `dds_demo_pg`, `dds_demo_both`, `dds_demo_none`
|
- DDS 전용 사용자: `dds_demo_my`, `dds_demo_pg`, `dds_demo_both`, `dds_demo_none`
|
||||||
@@ -14,11 +15,13 @@
|
|||||||
두 인스턴스는 프로세스·포트·실행 경계를 분리하지만, 화면에서 설명하는 관리 순서는 같습니다.
|
두 인스턴스는 프로세스·포트·실행 경계를 분리하지만, 화면에서 설명하는 관리 순서는 같습니다.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
사용자·그룹·역할 → 데이터 권한 규칙 → 보호 연결 → 실제 결과 확인
|
사용자·그룹·역할 → 데이터 권한 규칙 → DDS Grant 게시 → 보호 연결 → 실제 결과 확인
|
||||||
```
|
```
|
||||||
|
|
||||||
8083 DDS 화면의 `/users`, `/groups`, `/roles`, `/permissions`, `/effective-matrix`는 8082 VPD 화면과 같은 관리 기준을 보여줍니다. 다만 보호를 실행하는 DB 계층만 다릅니다. VPD 인스턴스는 공통 DB 계정과 Bearer 컨텍스트로 동적 predicate를 계산하고, DDS 인스턴스는 DDS `END USER`의 DATA ROLE/DATA GRANT로 직접 집행합니다.
|
8083 DDS 화면의 `/users`, `/groups`, `/roles`, `/permissions`, `/effective-matrix`는 8082 VPD 화면과 같은 관리 기준을 보여줍니다. 다만 보호를 실행하는 DB 계층만 다릅니다. VPD 인스턴스는 공통 DB 계정과 Bearer 컨텍스트로 동적 predicate를 계산하고, DDS 인스턴스는 DDS `END USER`의 DATA ROLE/DATA GRANT로 직접 집행합니다.
|
||||||
|
|
||||||
|
`/dds-provision`은 이 차이를 연결하는 명시적 배포 단계입니다. 직접 역할과 활성 그룹에서 상속된 역할을 합쳐 사용자별 유효 권한을 계산하고, 애플리케이션 객체 매핑을 거쳐 `CREATE OR REPLACE DATA GRANT` SQL을 미리 보여줍니다. 그룹을 DDS 그룹으로 복사하지 않고, 그룹이 상속한 역할을 해당 DDS DATA ROLE의 최종 predicate로 합칩니다. 매핑되지 않은 객체는 경고로 남기고 게시하지 않습니다.
|
||||||
|
|
||||||
| 데모 | 사용자 식별 | 정책 표현 | 권한 변경 |
|
| 데모 | 사용자 식별 | 정책 표현 | 권한 변경 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| VPD | 공통 계정 + Bearer/세션 컨텍스트 | 동적 predicate 함수 | 업무 권한 테이블 |
|
| VPD | 공통 계정 + Bearer/세션 컨텍스트 | 동적 predicate 함수 | 업무 권한 테이블 |
|
||||||
@@ -26,6 +29,15 @@
|
|||||||
|
|
||||||
따라서 두 인스턴스의 관리 흐름과 기대 결과는 맞출 수 있지만, Bearer 요청마다 권한을 계산하는 VPD 실행 모델을 DDS가 그대로 대체하는 것은 아닙니다. DDS로 Bearer 기반 호출을 운영하려면 지원 드라이버의 `EndUserSecurityContext` 전파가 별도로 필요합니다.
|
따라서 두 인스턴스의 관리 흐름과 기대 결과는 맞출 수 있지만, Bearer 요청마다 권한을 계산하는 VPD 실행 모델을 DDS가 그대로 대체하는 것은 아닙니다. DDS로 Bearer 기반 호출을 운영하려면 지원 드라이버의 `EndUserSecurityContext` 전파가 별도로 필요합니다.
|
||||||
|
|
||||||
|
권한 변경 후에는 다음 순서로 확인합니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. /permissions에서 사용자·그룹·역할·행/TAG·컬럼 기준을 저장
|
||||||
|
2. /dds-provision에서 사용자별 predicate와 제외 컬럼을 검토
|
||||||
|
3. 승인된 경우에만 DDS DATA GRANT 게시
|
||||||
|
4. /dds 또는 /vector-knowledge에서 END USER별 결과를 검증
|
||||||
|
```
|
||||||
|
|
||||||
이 앱의 조회 경계는 다음과 같습니다.
|
이 앱의 조회 경계는 다음과 같습니다.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -43,7 +55,7 @@ sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @sql/adb/31_dds_standalone_demo_setup
|
|||||||
sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @sql/adb/32_dds_vector_tag_setup.sql
|
sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @sql/adb/32_dds_vector_tag_setup.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
`32_dds_vector_tag_setup.sql`은 공통 청크·태그 저장소를 읽는 DDS 전용 VIEW를 만들고, `SPRING_BOOT`, `ORDS`, `ORACLE_VPD`, `MCP` 같은 기술 태그를 DATA GRANT의 `WHERE` 조건으로 연결합니다. 검색 화면에서는 같은 문서를 청킹·임베딩한 뒤 선택한 END USER로 직접 조회하므로, DDS grant가 없는 주체는 보호 VIEW 자체를 볼 수 없습니다.
|
`32_dds_vector_tag_setup.sql`은 공통 청크·태그 저장소를 읽는 DDS 전용 VIEW를 만들고, 동일한 `CB_PERMISSION`/`CB_PERMISSION_RULE`에 역할별 TAG 규칙을 등록합니다. `SPRING_BOOT`, `ORDS`, `ORACLE_VPD`, `MCP`처럼 한 역할에 여러 TAG가 있으면 `/dds-provision`이 OR 조건으로 합쳐 DDS DATA GRANT를 다시 만듭니다. 벡터 검색에 필요한 `EMBEDDING`은 DDS 엔진이 거리 계산에 사용하므로 Grant에서 제외하지 않고, 검색 응답에서는 애플리케이션이 반환하지 않습니다. 그 외 민감 컬럼은 애플리케이션 권한의 원문 허용 목록에 없으면 `ALL COLUMNS EXCEPT`로 제외됩니다. 검색 화면에서는 같은 문서를 청킹·임베딩한 뒤 선택한 END USER로 직접 조회하므로, DDS Grant가 없는 주체는 보호 VIEW 자체를 볼 수 없습니다.
|
||||||
|
|
||||||
그 다음 DDS 인스턴스를 실행합니다.
|
그 다음 DDS 인스턴스를 실행합니다.
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ public record DdsProperties(
|
|||||||
String pgObject,
|
String pgObject,
|
||||||
String myObject,
|
String myObject,
|
||||||
String vectorObject,
|
String vectorObject,
|
||||||
Map<String, User> users
|
Map<String, User> users,
|
||||||
|
Map<String, String> objectMappings
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private static final Pattern OBJECT_NAME = Pattern.compile(
|
private static final Pattern OBJECT_NAME = Pattern.compile(
|
||||||
@@ -37,6 +38,15 @@ public record DdsProperties(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
users = Map.copyOf(normalizedUsers);
|
users = Map.copyOf(normalizedUsers);
|
||||||
|
var normalizedMappings = new LinkedHashMap<String, String>();
|
||||||
|
if (objectMappings != null) {
|
||||||
|
objectMappings.forEach((key, value) -> {
|
||||||
|
if (key != null && value != null && !key.isBlank() && !value.isBlank()) {
|
||||||
|
normalizedMappings.put(key.trim().toUpperCase(), normalizeObject(value, ""));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
objectMappings = Map.copyOf(normalizedMappings);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,7 +62,19 @@ public record DdsProperties(
|
|||||||
Map<String, User> users
|
Map<String, User> users
|
||||||
) {
|
) {
|
||||||
this(dbUrl, queryTimeout, pgObject, myObject,
|
this(dbUrl, queryTimeout, pgObject, myObject,
|
||||||
"ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS", users);
|
"ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS", users, Map.of(
|
||||||
|
"CB_VECTOR_SEARCH_DOCUMENTS", "ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public DdsProperties(
|
||||||
|
String dbUrl,
|
||||||
|
Duration queryTimeout,
|
||||||
|
String pgObject,
|
||||||
|
String myObject,
|
||||||
|
String vectorObject,
|
||||||
|
Map<String, User> users
|
||||||
|
) {
|
||||||
|
this(dbUrl, queryTimeout, pgObject, myObject, vectorObject, users, Map.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
public String objectFor(String sourceKey) {
|
public String objectFor(String sourceKey) {
|
||||||
@@ -73,11 +95,37 @@ public record DdsProperties(
|
|||||||
return candidate.toUpperCase();
|
return candidate.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
public record User(String label, String description, String username, String password) {
|
public record User(
|
||||||
|
String label,
|
||||||
|
String description,
|
||||||
|
String username,
|
||||||
|
String password,
|
||||||
|
long applicationUserId,
|
||||||
|
String dataRole
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the six-field constructor as the configuration binding target.
|
||||||
|
* The four-field overload below exists only for the small query-service
|
||||||
|
* tests and older local callers; without an explicit binding marker,
|
||||||
|
* Spring Boot can select that overload and silently leave the application
|
||||||
|
* user/data-role mapping at its default values.
|
||||||
|
*/
|
||||||
|
@ConstructorBinding
|
||||||
|
public User {
|
||||||
|
}
|
||||||
|
|
||||||
|
public User(String label, String description, String username, String password) {
|
||||||
|
this(label, description, username, password, 0L, "");
|
||||||
|
}
|
||||||
|
|
||||||
public boolean configured() {
|
public boolean configured() {
|
||||||
return username != null && !username.isBlank()
|
return username != null && !username.isBlank()
|
||||||
&& password != null && !password.isBlank();
|
&& password != null && !password.isBlank();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean mapped() {
|
||||||
|
return applicationUserId > 0 && dataRole != null && !dataRole.isBlank();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.cloudhandson.ddsbackoffice.domain;
|
||||||
|
|
||||||
|
/** One generated DDS DATA GRANT and its management-model origin. */
|
||||||
|
public record DdsGrantPlan(
|
||||||
|
String userKey,
|
||||||
|
String userLabel,
|
||||||
|
long applicationUserId,
|
||||||
|
String dataRole,
|
||||||
|
String applicationObject,
|
||||||
|
String ddsObject,
|
||||||
|
String grantName,
|
||||||
|
String predicate,
|
||||||
|
String excludedColumns,
|
||||||
|
String sql,
|
||||||
|
boolean publishable,
|
||||||
|
String note
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.cloudhandson.ddsbackoffice.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Preview of the effective application permissions compiled to DDS grants. */
|
||||||
|
public record DdsProvisioningPlan(
|
||||||
|
List<DdsGrantPlan> grants,
|
||||||
|
List<String> warnings,
|
||||||
|
int mappedUserCount
|
||||||
|
) {
|
||||||
|
|
||||||
|
public DdsProvisioningPlan {
|
||||||
|
grants = grants == null ? List.of() : List.copyOf(grants);
|
||||||
|
warnings = warnings == null ? List.of() : List.copyOf(warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
public long publishableCount() {
|
||||||
|
return grants.stream().filter(DdsGrantPlan::publishable).count();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasWarnings() {
|
||||||
|
return !warnings.isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
package com.cloudhandson.ddsbackoffice.service;
|
||||||
|
|
||||||
|
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
|
||||||
|
import com.cloudhandson.ddsbackoffice.domain.DdsGrantPlan;
|
||||||
|
import com.cloudhandson.ddsbackoffice.domain.DdsProvisioningPlan;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import org.springframework.dao.DataAccessException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compiles the application's effective permission model into DDS grants.
|
||||||
|
*
|
||||||
|
* DDS does not evaluate CB_PERMISSION at query time. This service is the
|
||||||
|
* explicit publish boundary: application users/groups/roles are expanded,
|
||||||
|
* ALLOW/DENY rules are compiled into one predicate per user/object, and the
|
||||||
|
* resulting DATA GRANT is created or replaced. A publish with no ALLOW rule
|
||||||
|
* drops the reserved grant, preserving default deny.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class DdsGrantPublisher {
|
||||||
|
|
||||||
|
private static final Pattern IDENTIFIER = Pattern.compile("[A-Z][A-Z0-9_$#]*");
|
||||||
|
private static final Pattern OBJECT = Pattern.compile(
|
||||||
|
"[A-Z][A-Z0-9_$#]*(\\.[A-Z][A-Z0-9_$#]*)*"
|
||||||
|
);
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final DdsProperties properties;
|
||||||
|
|
||||||
|
public DdsGrantPublisher(JdbcTemplate jdbcTemplate, DdsProperties properties) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DdsProvisioningPlan preview() {
|
||||||
|
List<DdsGrantPlan> grants = new ArrayList<>();
|
||||||
|
List<String> warnings = new ArrayList<>();
|
||||||
|
int mappedUsers = 0;
|
||||||
|
|
||||||
|
for (Map.Entry<String, DdsProperties.User> entry : properties.users().entrySet()) {
|
||||||
|
String userKey = entry.getKey();
|
||||||
|
DdsProperties.User ddsUser = entry.getValue();
|
||||||
|
if (!ddsUser.mapped()) {
|
||||||
|
// The demo's "none" identity is deliberately a DDS-only default-deny
|
||||||
|
// subject. It has no application user mapping by design, so do not
|
||||||
|
// turn that expected state into a noisy warning.
|
||||||
|
if (ddsUser.applicationUserId() == 0L && "none".equals(userKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
warnings.add(userKey + ": 애플리케이션 사용자 ID 또는 DDS DATA ROLE 매핑이 없습니다.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
AppIdentity identity = findIdentity(ddsUser.applicationUserId());
|
||||||
|
if (identity == null) {
|
||||||
|
warnings.add(userKey + ": CB_APP_USER " + ddsUser.applicationUserId() + "를 찾을 수 없습니다.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!identity.active()) {
|
||||||
|
warnings.add(userKey + ": 애플리케이션 사용자가 비활성 상태입니다.");
|
||||||
|
}
|
||||||
|
mappedUsers++;
|
||||||
|
|
||||||
|
Map<Long, PermissionEntry> permissions = findEffectivePermissions(identity.userId());
|
||||||
|
// Include every approved mapping, not only objects that still have a
|
||||||
|
// permission row. This lets an explicit publish revoke a previously
|
||||||
|
// generated grant when an application permission is removed; the DDS
|
||||||
|
// side otherwise has no way to observe that deletion.
|
||||||
|
Set<String> targets = new LinkedHashSet<>(properties.objectMappings().keySet());
|
||||||
|
permissions.values().forEach(permission -> targets.add(permission.targetName()));
|
||||||
|
for (String target : targets) {
|
||||||
|
String normalizedTarget = target.toUpperCase(Locale.ROOT);
|
||||||
|
String ddsObject = properties.objectMappings().get(normalizedTarget);
|
||||||
|
String grantName = grantName(userKey, normalizedTarget);
|
||||||
|
if (ddsObject == null || ddsObject.isBlank()) {
|
||||||
|
warnings.add(userKey + ": " + normalizedTarget + "에 대한 DDS 보호 객체 매핑이 없습니다.");
|
||||||
|
grants.add(unpublishable(userKey, ddsUser, normalizedTarget, grantName,
|
||||||
|
"DDS object mapping 필요"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!OBJECT.matcher(ddsObject).matches()) {
|
||||||
|
warnings.add(userKey + ": 안전하지 않은 DDS 객체 매핑을 건너뛰었습니다: " + ddsObject);
|
||||||
|
grants.add(unpublishable(userKey, ddsUser, normalizedTarget, grantName,
|
||||||
|
"DDS object 이름 검증 실패"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ObjectInfo object = findProtectedObject(normalizedTarget);
|
||||||
|
if (object == null) {
|
||||||
|
warnings.add(userKey + ": 보호 객체 메타데이터가 없습니다: " + normalizedTarget);
|
||||||
|
grants.add(unpublishable(userKey, ddsUser, normalizedTarget, grantName,
|
||||||
|
"CB_PROTECTED_OBJECT 등록 필요"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!ddsObjectExists(ddsObject)) {
|
||||||
|
warnings.add(userKey + ": DDS 보호 객체가 DB에 없습니다: " + ddsObject);
|
||||||
|
grants.add(unpublishable(userKey, ddsUser, normalizedTarget, grantName,
|
||||||
|
"DDS VIEW/TABLE 생성 필요"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PermissionEntry> targetPermissions = permissions.values().stream()
|
||||||
|
.filter(permission -> normalizedTarget.equals(permission.targetName()))
|
||||||
|
.toList();
|
||||||
|
PredicateBuild predicate = buildPredicate(targetPermissions, identity, object);
|
||||||
|
String excludedColumns = excludedColumns(normalizedTarget, targetPermissions, object.objectId());
|
||||||
|
if (predicate.allow().isBlank()) {
|
||||||
|
grants.add(new DdsGrantPlan(
|
||||||
|
userKey, valueOrDefault(ddsUser.label(), userKey), identity.userId(),
|
||||||
|
ddsUser.dataRole(), normalizedTarget, ddsObject, grantName,
|
||||||
|
"1 = 0", excludedColumns, "", false,
|
||||||
|
"ALLOW 규칙이 없어 기존 DDS Grant를 회수합니다.")
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String where = predicate.expression();
|
||||||
|
String sql = createGrantSql(grantName, ddsObject, where, excludedColumns, ddsUser.dataRole());
|
||||||
|
grants.add(new DdsGrantPlan(
|
||||||
|
userKey, valueOrDefault(ddsUser.label(), userKey), identity.userId(),
|
||||||
|
ddsUser.dataRole(), normalizedTarget, ddsObject, grantName,
|
||||||
|
where, excludedColumns, sql, true,
|
||||||
|
"애플리케이션 유효 권한을 DDS DATA GRANT로 게시할 수 있습니다.")
|
||||||
|
);
|
||||||
|
warnings.addAll(predicate.warnings());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new DdsProvisioningPlan(grants, warnings, mappedUsers);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DdsProvisioningPlan publish() {
|
||||||
|
DdsProvisioningPlan plan = preview();
|
||||||
|
for (DdsGrantPlan grant : plan.grants()) {
|
||||||
|
// Never touch a grant for an application object that has no approved
|
||||||
|
// DDS object mapping. An operator may still manage that DDS object by a
|
||||||
|
// separate process; the preview warning must not become an accidental
|
||||||
|
// revoke. Known mappings are safe to replace or revoke.
|
||||||
|
if (grant.ddsObject() == null || grant.ddsObject().isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
dropGrant(grant.grantName());
|
||||||
|
if (grant.publishable()) {
|
||||||
|
jdbcTemplate.execute(grant.sql());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<Long, PermissionEntry> findEffectivePermissions(long userId) {
|
||||||
|
String sql = """
|
||||||
|
WITH effective_role AS (
|
||||||
|
SELECT ur.role_id
|
||||||
|
FROM cb_user_role ur
|
||||||
|
WHERE ur.user_id = ?
|
||||||
|
UNION
|
||||||
|
SELECT gr.role_id
|
||||||
|
FROM cb_user_group ug
|
||||||
|
JOIN cb_app_group g ON g.group_id = ug.group_id AND g.active_yn = 'Y'
|
||||||
|
JOIN cb_group_role gr ON gr.group_id = ug.group_id
|
||||||
|
WHERE ug.user_id = ?
|
||||||
|
)
|
||||||
|
SELECT p.perm_id, p.target_name, p.permission_effect,
|
||||||
|
r.rule_id, r.rule_column, r.rule_type, r.rule_value
|
||||||
|
FROM effective_role er
|
||||||
|
JOIN cb_permission p ON p.role_id = er.role_id
|
||||||
|
LEFT JOIN cb_permission_rule r ON r.perm_id = p.perm_id
|
||||||
|
WHERE p.action_name = 'SELECT'
|
||||||
|
ORDER BY p.perm_id, r.rule_id
|
||||||
|
""";
|
||||||
|
Map<Long, PermissionEntry> result = new LinkedHashMap<>();
|
||||||
|
List<RawPermissionRow> rows = jdbcTemplate.query(sql, (row, ignored) -> new RawPermissionRow(
|
||||||
|
row.getLong("perm_id"),
|
||||||
|
row.getString("target_name"),
|
||||||
|
row.getString("permission_effect"),
|
||||||
|
row.getString("rule_column"),
|
||||||
|
row.getString("rule_type"),
|
||||||
|
row.getString("rule_value")
|
||||||
|
), userId, userId);
|
||||||
|
for (RawPermissionRow row : rows) {
|
||||||
|
long permissionId = row.permissionId();
|
||||||
|
PermissionEntry permission = result.computeIfAbsent(permissionId, ignored ->
|
||||||
|
new PermissionEntry(
|
||||||
|
permissionId,
|
||||||
|
upper(row.targetName()),
|
||||||
|
normalizeEffect(row.permissionEffect()),
|
||||||
|
new ArrayList<>(),
|
||||||
|
findVisibleColumns(permissionId)
|
||||||
|
));
|
||||||
|
if (row.ruleType() != null && !row.ruleType().isBlank()) {
|
||||||
|
permission.rules().add(new Rule(
|
||||||
|
upperNullable(row.ruleColumn()),
|
||||||
|
upper(row.ruleType()),
|
||||||
|
row.ruleValue()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> findVisibleColumns(long permissionId) {
|
||||||
|
return jdbcTemplate.query(
|
||||||
|
"SELECT column_name FROM cb_permission_column WHERE permission_id = ? ORDER BY column_name",
|
||||||
|
(row, ignored) -> upper(row.getString("column_name")), permissionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AppIdentity findIdentity(long userId) {
|
||||||
|
List<AppIdentity> rows = jdbcTemplate.query(
|
||||||
|
"SELECT user_id, employee_no, dept_code, active FROM cb_app_user WHERE user_id = ?",
|
||||||
|
(row, ignored) -> new AppIdentity(
|
||||||
|
row.getLong("user_id"),
|
||||||
|
row.getString("employee_no"),
|
||||||
|
row.getString("dept_code"),
|
||||||
|
"Y".equalsIgnoreCase(row.getString("active"))
|
||||||
|
), userId);
|
||||||
|
return rows.isEmpty() ? null : rows.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ObjectInfo findProtectedObject(String targetName) {
|
||||||
|
List<ObjectInfo> rows = jdbcTemplate.query(
|
||||||
|
"SELECT object_id, owner, object_name FROM cb_protected_object WHERE object_name = UPPER(?)",
|
||||||
|
(row, ignored) -> new ObjectInfo(
|
||||||
|
row.getLong("object_id"), upper(row.getString("owner")), upper(row.getString("object_name"))),
|
||||||
|
targetName);
|
||||||
|
return rows.isEmpty() ? null : rows.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> sensitiveColumns(long objectId) {
|
||||||
|
return jdbcTemplate.query(
|
||||||
|
"SELECT column_name FROM cb_protected_column WHERE object_id = ? AND sensitive_yn = 'Y' ORDER BY column_id",
|
||||||
|
(row, ignored) -> upper(row.getString("column_name")), objectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean ddsObjectExists(String objectName) {
|
||||||
|
String[] parts = objectName.split("\\.", 2);
|
||||||
|
if (parts.length != 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Integer count = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM all_objects WHERE owner = ? AND object_name = ? AND object_type IN ('TABLE','VIEW')",
|
||||||
|
Integer.class, parts[0], parts[1]);
|
||||||
|
return count != null && count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private PredicateBuild buildPredicate(
|
||||||
|
List<PermissionEntry> permissions,
|
||||||
|
AppIdentity identity,
|
||||||
|
ObjectInfo object
|
||||||
|
) {
|
||||||
|
List<String> allow = new ArrayList<>();
|
||||||
|
List<String> deny = new ArrayList<>();
|
||||||
|
List<String> warnings = new ArrayList<>();
|
||||||
|
for (PermissionEntry permission : permissions) {
|
||||||
|
List<String> clauses = new ArrayList<>();
|
||||||
|
for (Rule rule : permission.rules()) {
|
||||||
|
String clause = compileRule(rule, identity, object);
|
||||||
|
if (clause == null || clause.isBlank()) {
|
||||||
|
warnings.add(permission.targetName() + ": 지원하지 않거나 컬럼을 확인할 수 없는 규칙을 건너뛰었습니다: "
|
||||||
|
+ rule.ruleType());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
clauses.add(clause);
|
||||||
|
}
|
||||||
|
if (clauses.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String expression = clauses.size() == 1 ? clauses.get(0) : "(" + String.join(" OR ", clauses) + ")";
|
||||||
|
if ("DENY".equals(permission.effect())) {
|
||||||
|
deny.add(expression);
|
||||||
|
} else {
|
||||||
|
allow.add(expression);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (allow.isEmpty()) {
|
||||||
|
return new PredicateBuild("", "", warnings);
|
||||||
|
}
|
||||||
|
String allowExpression = allow.size() == 1 ? allow.get(0) : "(" + String.join(" OR ", allow) + ")";
|
||||||
|
if (deny.isEmpty()) {
|
||||||
|
return new PredicateBuild(allowExpression, allowExpression, warnings);
|
||||||
|
}
|
||||||
|
String denyExpression = deny.size() == 1 ? deny.get(0) : "(" + String.join(" OR ", deny) + ")";
|
||||||
|
return new PredicateBuild(allowExpression, "(" + allowExpression + ") AND NOT (" + denyExpression + ")", warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String compileRule(Rule rule, AppIdentity identity, ObjectInfo object) {
|
||||||
|
String type = rule.ruleType();
|
||||||
|
if ("ALL".equals(type)) {
|
||||||
|
return "1 = 1";
|
||||||
|
}
|
||||||
|
String column = rule.ruleColumn();
|
||||||
|
if (column == null || column.isBlank()) {
|
||||||
|
column = switch (type) {
|
||||||
|
case "MY_DEPT", "DEPT" -> "DEPT_CODE";
|
||||||
|
case "SELF", "EMP_NO" -> "OWNER_EMP_NO";
|
||||||
|
case "TAG" -> "TECH_TAG";
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (column == null || !IDENTIFIER.matcher(column).matches() || !hasColumn(object, column)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return switch (type) {
|
||||||
|
case "MY_DEPT" -> identity.deptCode() == null ? null : column + " = " + quote(identity.deptCode());
|
||||||
|
case "SELF" -> identity.employeeNo() == null ? null : column + " = " + quote(identity.employeeNo());
|
||||||
|
case "DEPT", "EMP_NO", "=" -> rule.ruleValue() == null ? null
|
||||||
|
: "TO_CHAR(" + column + ") = " + quote(rule.ruleValue());
|
||||||
|
case "!=", "<>" -> rule.ruleValue() == null ? null
|
||||||
|
: "TO_CHAR(" + column + ") <> " + quote(rule.ruleValue());
|
||||||
|
case "TAG" -> rule.ruleValue() == null || !rule.ruleValue().trim().matches("[A-Za-z0-9_-]+") ? null
|
||||||
|
: "REGEXP_LIKE(UPPER(" + column + "), "
|
||||||
|
+ quote("(^|,)" + rule.ruleValue().trim().toUpperCase(Locale.ROOT) + "(,|$)") + ")";
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasColumn(ObjectInfo object, String column) {
|
||||||
|
Integer count = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM all_tab_columns WHERE owner = ? AND table_name = ? AND column_name = ?",
|
||||||
|
Integer.class, object.owner(), object.objectName(), column);
|
||||||
|
return count != null && count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String excludedColumns(
|
||||||
|
String applicationObject,
|
||||||
|
List<PermissionEntry> permissions,
|
||||||
|
long objectId
|
||||||
|
) {
|
||||||
|
Set<String> visible = new LinkedHashSet<>();
|
||||||
|
permissions.stream()
|
||||||
|
.filter(permission -> "ALLOW".equals(permission.effect()))
|
||||||
|
.forEach(permission -> visible.addAll(permission.visibleColumns()));
|
||||||
|
return sensitiveColumns(objectId).stream()
|
||||||
|
// VECTOR_DISTANCE needs the VECTOR column while DDS evaluates the
|
||||||
|
// search. The service deliberately omits EMBEDDING from its response,
|
||||||
|
// but excluding a VECTOR column from DATA GRANT makes the predicate
|
||||||
|
// invalid at runtime (ORA-52561). Keep it available to the engine and
|
||||||
|
// enforce response shaping in DdsVectorKnowledgeService instead.
|
||||||
|
.filter(column -> !("CB_VECTOR_SEARCH_DOCUMENTS".equals(applicationObject)
|
||||||
|
&& "EMBEDDING".equals(column)))
|
||||||
|
.filter(column -> !visible.contains(column))
|
||||||
|
.reduce((left, right) -> left + ", " + right)
|
||||||
|
.orElse("");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String createGrantSql(
|
||||||
|
String grantName,
|
||||||
|
String ddsObject,
|
||||||
|
String predicate,
|
||||||
|
String excludedColumns,
|
||||||
|
String dataRole
|
||||||
|
) {
|
||||||
|
String select = excludedColumns == null || excludedColumns.isBlank()
|
||||||
|
? "AS SELECT"
|
||||||
|
: "AS SELECT (ALL COLUMNS EXCEPT " + excludedColumns + ")";
|
||||||
|
return "CREATE OR REPLACE DATA GRANT ADMIN." + grantName
|
||||||
|
+ " " + select
|
||||||
|
+ " ON " + ddsObject
|
||||||
|
+ " WHERE " + predicate
|
||||||
|
+ " TO " + safeRole(dataRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void dropGrant(String grantName) {
|
||||||
|
try {
|
||||||
|
jdbcTemplate.execute("DROP DATA GRANT ADMIN." + grantName);
|
||||||
|
} catch (DataAccessException ignored) {
|
||||||
|
// The first publish has no generated grant yet. DDS returns an object
|
||||||
|
// not found error, which is intentionally harmless here.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DdsGrantPlan unpublishable(
|
||||||
|
String userKey,
|
||||||
|
DdsProperties.User user,
|
||||||
|
String target,
|
||||||
|
String grantName,
|
||||||
|
String note
|
||||||
|
) {
|
||||||
|
return new DdsGrantPlan(
|
||||||
|
userKey, valueOrDefault(user.label(), userKey), user.applicationUserId(), user.dataRole(),
|
||||||
|
target, properties.objectMappings().getOrDefault(target, ""), grantName,
|
||||||
|
"", "", "", false, note);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String grantName(String userKey, String target) {
|
||||||
|
if ("CB_VECTOR_SEARCH_DOCUMENTS".equals(target)) {
|
||||||
|
return "DDS_DEMO_" + userKey.toUpperCase(Locale.ROOT) + "_VECTOR_GRANT";
|
||||||
|
}
|
||||||
|
String candidate = "DDS_" + userKey.toUpperCase(Locale.ROOT) + "_" + target + "_GRANT";
|
||||||
|
return candidate.length() <= 120 ? candidate : candidate.substring(0, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safeRole(String value) {
|
||||||
|
String normalized = upper(value);
|
||||||
|
if (normalized == null || !OBJECT.matcher(normalized).matches()) {
|
||||||
|
throw new IllegalArgumentException("안전하지 않은 DDS DATA ROLE 이름입니다: " + value);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String quote(String value) {
|
||||||
|
return "'" + value.replace("'", "''") + "'";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeEffect(String value) {
|
||||||
|
return "DENY".equalsIgnoreCase(value) ? "DENY" : "ALLOW";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String upper(String value) {
|
||||||
|
return value == null ? null : value.trim().toUpperCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String upperNullable(String value) {
|
||||||
|
return value == null || value.isBlank() ? null : upper(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String valueOrDefault(String value, String fallback) {
|
||||||
|
return value == null || value.isBlank() ? fallback : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private record AppIdentity(long userId, String employeeNo, String deptCode, boolean active) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ObjectInfo(long objectId, String owner, String objectName) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Rule(String ruleColumn, String ruleType, String ruleValue) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record RawPermissionRow(
|
||||||
|
long permissionId,
|
||||||
|
String targetName,
|
||||||
|
String permissionEffect,
|
||||||
|
String ruleColumn,
|
||||||
|
String ruleType,
|
||||||
|
String ruleValue
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record PermissionEntry(
|
||||||
|
long permissionId,
|
||||||
|
String targetName,
|
||||||
|
String effect,
|
||||||
|
List<Rule> rules,
|
||||||
|
List<String> visibleColumns
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record PredicateBuild(String allow, String expression, List<String> warnings) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.cloudhandson.ddsbackoffice.web;
|
||||||
|
|
||||||
|
import com.cloudhandson.ddsbackoffice.domain.DdsProvisioningPlan;
|
||||||
|
import com.cloudhandson.ddsbackoffice.service.DdsGrantPublisher;
|
||||||
|
import org.springframework.dao.DataAccessException;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
public class DdsProvisionController {
|
||||||
|
|
||||||
|
private final DdsGrantPublisher publisher;
|
||||||
|
|
||||||
|
public DdsProvisionController(DdsGrantPublisher publisher) {
|
||||||
|
this.publisher = publisher;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/dds-provision")
|
||||||
|
public String page(Model model) {
|
||||||
|
loadPlan(model);
|
||||||
|
return "dds-provision";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/dds-provision/publish")
|
||||||
|
public String publish(RedirectAttributes redirectAttributes) {
|
||||||
|
try {
|
||||||
|
DdsProvisioningPlan plan = publisher.publish();
|
||||||
|
redirectAttributes.addFlashAttribute(
|
||||||
|
"successMessage",
|
||||||
|
plan.publishableCount() + "개 DDS DATA GRANT를 게시했습니다. 권한 없는 대상의 기존 Grant는 회수했습니다."
|
||||||
|
);
|
||||||
|
if (plan.hasWarnings()) {
|
||||||
|
redirectAttributes.addFlashAttribute("warningMessage", String.join(" / ", plan.warnings()));
|
||||||
|
}
|
||||||
|
} catch (DataAccessException | IllegalArgumentException exception) {
|
||||||
|
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
|
||||||
|
}
|
||||||
|
return "redirect:/dds-provision";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadPlan(Model model) {
|
||||||
|
try {
|
||||||
|
model.addAttribute("plan", publisher.preview());
|
||||||
|
} catch (DataAccessException | IllegalArgumentException exception) {
|
||||||
|
model.addAttribute("plan", new DdsProvisioningPlan(java.util.List.of(),
|
||||||
|
java.util.List.of(exception.getMessage()), 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,24 +61,35 @@ dds:
|
|||||||
pg-object: ${DDS_BACKOFFICE_PG_OBJECT:ADMIN.V_DDS_CUSTOMERS_PG}
|
pg-object: ${DDS_BACKOFFICE_PG_OBJECT:ADMIN.V_DDS_CUSTOMERS_PG}
|
||||||
my-object: ${DDS_BACKOFFICE_MY_OBJECT:ADMIN.V_DDS_CUSTOMERS_MY}
|
my-object: ${DDS_BACKOFFICE_MY_OBJECT:ADMIN.V_DDS_CUSTOMERS_MY}
|
||||||
vector-object: ${DDS_BACKOFFICE_VECTOR_OBJECT:ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS}
|
vector-object: ${DDS_BACKOFFICE_VECTOR_OBJECT:ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS}
|
||||||
|
object-mappings:
|
||||||
|
CB_VECTOR_SEARCH_DOCUMENTS: ${DDS_BACKOFFICE_VECTOR_OBJECT:ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS}
|
||||||
|
CB_V_SEARCH_DOCUMENTS: ${DDS_BACKOFFICE_DOCUMENT_OBJECT:ADMIN.CB_DDS_V_SEARCH_DOCUMENTS}
|
||||||
users:
|
users:
|
||||||
my:
|
my:
|
||||||
label: MY 전용 사용자
|
label: MY 전용 사용자
|
||||||
description: MySQL 원본만 허용하는 DATA ROLE
|
description: MySQL 원본만 허용하는 DATA ROLE
|
||||||
username: ${DDS_BACKOFFICE_MY_USERNAME:dds_demo_my}
|
username: ${DDS_BACKOFFICE_MY_USERNAME:dds_demo_my}
|
||||||
password: ${DDS_BACKOFFICE_MY_PASSWORD:${DDSUSER_MY_PASSWORD:}}
|
password: ${DDS_BACKOFFICE_MY_PASSWORD:${DDSUSER_MY_PASSWORD:}}
|
||||||
|
application-user-id: ${DDS_BACKOFFICE_MY_APPLICATION_USER_ID:101}
|
||||||
|
data-role: ${DDS_BACKOFFICE_MY_DATA_ROLE:dds_demo_my_role}
|
||||||
pg:
|
pg:
|
||||||
label: PG 전용 사용자
|
label: PG 전용 사용자
|
||||||
description: PostgreSQL 원본만 허용하는 DATA ROLE
|
description: PostgreSQL 원본만 허용하는 DATA ROLE
|
||||||
username: ${DDS_BACKOFFICE_PG_USERNAME:dds_demo_pg}
|
username: ${DDS_BACKOFFICE_PG_USERNAME:dds_demo_pg}
|
||||||
password: ${DDS_BACKOFFICE_PG_PASSWORD:${DDSUSER_PG_PASSWORD:}}
|
password: ${DDS_BACKOFFICE_PG_PASSWORD:${DDSUSER_PG_PASSWORD:}}
|
||||||
|
application-user-id: ${DDS_BACKOFFICE_PG_APPLICATION_USER_ID:102}
|
||||||
|
data-role: ${DDS_BACKOFFICE_PG_DATA_ROLE:dds_demo_pg_role}
|
||||||
both:
|
both:
|
||||||
label: 통합 사용자
|
label: 통합 사용자
|
||||||
description: 두 원본을 모두 허용하는 DATA ROLE
|
description: 두 원본을 모두 허용하는 DATA ROLE
|
||||||
username: ${DDS_BACKOFFICE_BOTH_USERNAME:dds_demo_both}
|
username: ${DDS_BACKOFFICE_BOTH_USERNAME:dds_demo_both}
|
||||||
password: ${DDS_BACKOFFICE_BOTH_PASSWORD:${DDSUSER_BOTH_PASSWORD:}}
|
password: ${DDS_BACKOFFICE_BOTH_PASSWORD:${DDSUSER_BOTH_PASSWORD:}}
|
||||||
|
application-user-id: ${DDS_BACKOFFICE_BOTH_APPLICATION_USER_ID:103}
|
||||||
|
data-role: ${DDS_BACKOFFICE_BOTH_DATA_ROLE:dds_demo_both_role}
|
||||||
none:
|
none:
|
||||||
label: 차단 사용자
|
label: 차단 사용자
|
||||||
description: 접속만 가능하고 DATA GRANT가 없는 사용자
|
description: 접속만 가능하고 DATA GRANT가 없는 사용자
|
||||||
username: ${DDS_BACKOFFICE_NONE_USERNAME:dds_demo_none}
|
username: ${DDS_BACKOFFICE_NONE_USERNAME:dds_demo_none}
|
||||||
password: ${DDS_BACKOFFICE_NONE_PASSWORD:${DDSUSER_NONE_PASSWORD:}}
|
password: ${DDS_BACKOFFICE_NONE_PASSWORD:${DDSUSER_NONE_PASSWORD:}}
|
||||||
|
application-user-id: ${DDS_BACKOFFICE_NONE_APPLICATION_USER_ID:0}
|
||||||
|
data-role: ${DDS_BACKOFFICE_NONE_DATA_ROLE:dds_demo_none_role}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<header class="page-title guided-hero">
|
<header class="page-title guided-hero">
|
||||||
<span class="architecture-kicker">DEEP DATA SECURITY · INDEPENDENT TRACK</span>
|
<span class="architecture-kicker">DEEP DATA SECURITY · INDEPENDENT TRACK</span>
|
||||||
<h1>같은 권한 기준을 DDS 방식으로 적용하고 확인합니다.</h1>
|
<h1>같은 권한 기준을 DDS 방식으로 적용하고 확인합니다.</h1>
|
||||||
<p class="context-summary">권한 설계 → DDS 보호 연결 → END USER 검증 → 지식 검색 결과 확인</p>
|
<p class="context-summary">권한 설계 → DDS 보호 연결 → 권한 반영 → END USER 검증 → 지식 검색 결과 확인</p>
|
||||||
<details class="explanation-details">
|
<details class="explanation-details">
|
||||||
<summary>이 인스턴스의 역할 보기</summary>
|
<summary>이 인스턴스의 역할 보기</summary>
|
||||||
<p>8083 DDS 인스턴스는 8082 VPD 인스턴스와 별도로 실행됩니다. 두 화면은 사용자·그룹·역할·권한 규칙을 같은 관리 흐름으로 보여주지만, DDS는 END USER·DATA ROLE·DATA GRANT를 통해 데이터를 보호합니다.</p>
|
<p>8083 DDS 인스턴스는 8082 VPD 인스턴스와 별도로 실행됩니다. 두 화면은 사용자·그룹·역할·권한 규칙을 같은 관리 흐름으로 보여주지만, DDS는 END USER·DATA ROLE·DATA GRANT를 통해 데이터를 보호합니다.</p>
|
||||||
@@ -20,8 +20,9 @@
|
|||||||
<section class="journey-grid" aria-label="DDS 권한 적용 네 단계">
|
<section class="journey-grid" aria-label="DDS 권한 적용 네 단계">
|
||||||
<a class="journey-card" href="/permissions"><span class="journey-number">1</span><div><h2>1. 권한 설계</h2><p>사용자·그룹·역할에 객체와 TAG 규칙을 연결합니다.</p><strong>권한 규칙 만들기 →</strong></div></a>
|
<a class="journey-card" href="/permissions"><span class="journey-number">1</span><div><h2>1. 권한 설계</h2><p>사용자·그룹·역할에 객체와 TAG 규칙을 연결합니다.</p><strong>권한 규칙 만들기 →</strong></div></a>
|
||||||
<a class="journey-card" href="/vpd-policies"><span class="journey-number">2</span><div><h2>2. DDS 보호 연결</h2><p>공통 규칙을 DDS 전용 VIEW와 DATA GRANT에 연결합니다.</p><strong>DDS 보호 객체 확인 →</strong></div></a>
|
<a class="journey-card" href="/vpd-policies"><span class="journey-number">2</span><div><h2>2. DDS 보호 연결</h2><p>공통 규칙을 DDS 전용 VIEW와 DATA GRANT에 연결합니다.</p><strong>DDS 보호 객체 확인 →</strong></div></a>
|
||||||
<a class="journey-card" href="/dds"><span class="journey-number">3</span><div><h2>3. END USER 검증</h2><p>실제 DDS END USER로 접속해 허용·차단 결과를 확인합니다.</p><strong>직접 조회 실행 →</strong></div></a>
|
<a class="journey-card" href="/dds-provision"><span class="journey-number">3</span><div><h2>3. DDS 권한 반영</h2><p>그룹 상속을 포함한 유효 권한을 DDS Grant로 게시합니다.</p><strong>Grant 미리보기·게시 →</strong></div></a>
|
||||||
<a class="journey-card" href="/vector-knowledge"><span class="journey-number">4</span><div><h2>4. 지식 검색</h2><p>청크·임베딩·태그와 DDS 권한을 결합한 검색 시나리오를 확인합니다.</p><strong>권한 기반 검색 →</strong></div></a>
|
<a class="journey-card" href="/dds"><span class="journey-number">4</span><div><h2>4. END USER 검증</h2><p>실제 DDS END USER로 접속해 허용·차단 결과를 확인합니다.</p><strong>직접 조회 실행 →</strong></div></a>
|
||||||
|
<a class="journey-card" href="/vector-knowledge"><span class="journey-number">5</span><div><h2>5. 지식 검색</h2><p>청크·임베딩·태그와 DDS 권한을 결합한 검색 시나리오를 확인합니다.</p><strong>권한 기반 검색 →</strong></div></a>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="content-band macro-micro-grid">
|
<section class="content-band macro-micro-grid">
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head th:replace="~{fragments/layout :: head('DDS 권한 반영')}"></head>
|
||||||
|
<body>
|
||||||
|
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
||||||
|
<main class="container py-4">
|
||||||
|
<div class="page-title">
|
||||||
|
<span class="architecture-kicker">DDS · PUBLISH</span>
|
||||||
|
<h1>애플리케이션 권한을 DDS에 반영</h1>
|
||||||
|
<p class="context-summary">사용자·그룹·역할·테이블 권한을 계산한 뒤 DDS DATA GRANT로 게시합니다.</p>
|
||||||
|
<details class="explanation-details">
|
||||||
|
<summary>VPD와 DDS의 반영 차이 보기</summary>
|
||||||
|
<p>VPD는 요청 때마다 권한 테이블을 읽습니다. DDS는 같은 권한체계를 배포 시점에 DATA GRANT로 컴파일합니다. 그래서 권한 저장 후 이 화면에서 변경 내용을 미리 확인하고 게시해야 합니다.</p>
|
||||||
|
<p class="mb-0">그룹 자체를 DDS 그룹으로 복사하지는 않습니다. 애플리케이션 그룹의 역할 상속을 계산해 매핑된 DDS DATA ROLE에 하나의 최종 predicate로 합칩니다.</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section th:replace="~{fragments/layout :: architectureStrip('vpd')}"></section>
|
||||||
|
|
||||||
|
<section class="content-band">
|
||||||
|
<details class="explanation-details">
|
||||||
|
<summary>VPD 권한과 DDS 객체의 매핑 기준</summary>
|
||||||
|
<div class="table-responsive mt-3">
|
||||||
|
<table class="table table-sm align-middle mb-0">
|
||||||
|
<thead><tr><th>공통 권한 관리</th><th>DDS에서의 표현</th><th>반영 시점</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>애플리케이션 사용자</td><td>매핑된 DDS END USER + DATA ROLE</td><td>게시할 때 매핑 확인</td></tr>
|
||||||
|
<tr><td>그룹과 그룹에 연결된 역할</td><td>그룹을 복사하지 않고 최종 predicate로 합산</td><td>미리보기·게시 때 계산</td></tr>
|
||||||
|
<tr><td>테이블·VIEW SELECT 권한</td><td>보호 객체별 <code>DATA GRANT ... WHERE ...</code></td><td>권한 변경 후 게시</td></tr>
|
||||||
|
<tr><td>원문 표시 허용 컬럼</td><td><code>AS SELECT</code> 또는 <code>ALL COLUMNS EXCEPT</code></td><td>권한 변경 후 게시</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted mt-3 mb-0">VPD는 요청마다 권한 테이블을 평가하지만, DDS는 게시된 Grant가 바뀔 때까지 이전 선언을 계속 사용합니다. 이 화면의 게시 버튼이 두 모델을 동기화하는 경계입니다.</p>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="alert alert-success" th:if="${successMessage}" th:text="${successMessage}"></div>
|
||||||
|
<div class="alert alert-warning" th:if="${warningMessage}" th:text="${warningMessage}"></div>
|
||||||
|
<div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}"></div>
|
||||||
|
|
||||||
|
<section class="content-band">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<span class="architecture-kicker">1 · COMPILE</span>
|
||||||
|
<h2>현재 유효 권한 미리보기</h2>
|
||||||
|
<p class="section-subtitle">직접 부여된 역할과 활성 그룹에서 상속된 역할을 합쳐 DDS Grant를 만듭니다.</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge text-bg-secondary" th:text="${plan.mappedUserCount() + '명 매핑'}">0명 매핑</span>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-info" th:if="${plan.publishableCount() == 0}">
|
||||||
|
게시할 수 있는 ALLOW 권한이 없습니다. 보호 객체 매핑과 권한 규칙을 먼저 확인하세요.
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/dds-provision/publish" class="mb-3" th:if="${plan.publishableCount() > 0}">
|
||||||
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
|
<button class="btn rw-btn-primary" type="submit"
|
||||||
|
onclick="return confirm('현재 미리보기대로 DDS DATA GRANT를 교체하고 권한 없는 Grant를 회수할까요?')">미리보기대로 DDS에 게시</button>
|
||||||
|
</form>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle">
|
||||||
|
<thead><tr><th>애플리케이션 주체</th><th>애플리케이션 객체</th><th>DDS 주체/객체</th><th>판정</th><th>predicate</th><th>컬럼 제외</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr th:each="grant : ${plan.grants()}">
|
||||||
|
<td><strong th:text="${grant.userLabel()}">사용자</strong><small class="d-block text-muted" th:text="${'userId=' + grant.applicationUserId() + ' · ' + grant.dataRole()}">mapping</small></td>
|
||||||
|
<td><code th:text="${grant.applicationObject()}">CB_VECTOR_SEARCH_DOCUMENTS</code></td>
|
||||||
|
<td><code th:text="${grant.ddsObject()}">ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS</code><small class="d-block text-muted" th:text="${grant.grantName()}">GRANT</small></td>
|
||||||
|
<td>
|
||||||
|
<span class="badge text-bg-success" th:if="${grant.publishable()}">게시 가능</span>
|
||||||
|
<span class="badge text-bg-warning" th:unless="${grant.publishable()}">검토 필요</span>
|
||||||
|
<small class="d-block text-muted" th:text="${grant.note()}">메모</small>
|
||||||
|
</td>
|
||||||
|
<td><code th:text="${grant.predicate() ?: '-'}">predicate</code></td>
|
||||||
|
<td th:text="${grant.excludedColumns() ?: '없음'}">없음</td>
|
||||||
|
</tr>
|
||||||
|
<tr th:if="${#lists.isEmpty(plan.grants())}"><td colspan="6" class="text-muted">매핑된 유효 권한에서 DDS Grant 대상을 찾지 못했습니다.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="content-band" th:if="${plan.hasWarnings()}">
|
||||||
|
<div class="section-heading"><div><h2>반영 전 확인할 항목</h2><p class="section-subtitle">지원되지 않는 객체나 매핑 누락은 게시하지 않고 경고로 남깁니다. DDS 객체 매핑이 없는 기존 Grant도 이 화면에서는 건드리지 않습니다.</p></div></div>
|
||||||
|
<ul>
|
||||||
|
<li th:each="warning : ${plan.warnings()}" th:text="${warning}">경고</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="content-band" th:if="${plan.publishableCount() > 0}">
|
||||||
|
<details class="explanation-details">
|
||||||
|
<summary>게시되는 SQL 보기</summary>
|
||||||
|
<div th:each="grant : ${plan.grants()}" th:if="${grant.publishable()}" class="mt-3">
|
||||||
|
<strong th:text="${grant.grantName()}">GRANT</strong>
|
||||||
|
<pre class="code-block" th:text="${grant.sql()}">CREATE OR REPLACE DATA GRANT ...</pre>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
<div class="rw-menu-panel">
|
<div class="rw-menu-panel">
|
||||||
<a class="nav-link" href="/vpd-policies"
|
<a class="nav-link" href="/vpd-policies"
|
||||||
th:text="${backofficeTrack == 'DDS' ? 'DDS 보호 연결' : 'DB 보호 연결'}">DB 보호 연결</a>
|
th:text="${backofficeTrack == 'DDS' ? 'DDS 보호 연결' : 'DB 보호 연결'}">DB 보호 연결</a>
|
||||||
|
<a class="nav-link" href="/dds-provision" th:if="${backofficeTrack == 'DDS'}">DDS 권한 반영</a>
|
||||||
<a class="nav-link" href="/tokens" th:if="${backofficeTrack != 'DDS'}">검증 세션 발급</a>
|
<a class="nav-link" href="/tokens" th:if="${backofficeTrack != 'DDS'}">검증 세션 발급</a>
|
||||||
<a class="nav-link" href="/probe" th:if="${backofficeTrack != 'DDS'}">권한 결과 확인</a>
|
<a class="nav-link" href="/probe" th:if="${backofficeTrack != 'DDS'}">권한 결과 확인</a>
|
||||||
<a class="nav-link" href="/dds">DDS 직접 조회</a>
|
<a class="nav-link" href="/dds">DDS 직접 조회</a>
|
||||||
|
|||||||
@@ -74,7 +74,10 @@
|
|||||||
<h2>권한 규칙은 한 곳에서 관리합니다.</h2>
|
<h2>권한 규칙은 한 곳에서 관리합니다.</h2>
|
||||||
<p class="section-subtitle">사용자·그룹·역할·TAG 규칙은 VPD와 같은 권한 관리 화면에서 작성합니다.</p>
|
<p class="section-subtitle">사용자·그룹·역할·TAG 규칙은 VPD와 같은 권한 관리 화면에서 작성합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn btn-sm rw-btn-primary" href="/permissions">권한 관리 열기</a>
|
<div class="d-flex gap-2">
|
||||||
|
<a class="btn btn-sm rw-btn-secondary" href="/permissions">권한 관리 열기</a>
|
||||||
|
<a class="btn btn-sm rw-btn-primary" href="/dds-provision">DDS 권한 반영</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="macro-micro-grid">
|
<div class="macro-micro-grid">
|
||||||
<div><h3>관리 관점</h3><p>역할에 <code>ALLOW TAG</code>를 여러 개 등록하면 태그 중 하나라도 맞는 청크를 허용합니다. <code>DENY TAG</code>는 허용 후보에서 제외합니다.</p></div>
|
<div><h3>관리 관점</h3><p>역할에 <code>ALLOW TAG</code>를 여러 개 등록하면 태그 중 하나라도 맞는 청크를 허용합니다. <code>DENY TAG</code>는 허용 후보에서 제외합니다.</p></div>
|
||||||
|
|||||||
@@ -27,7 +27,10 @@
|
|||||||
<h2>VPD와 동일한 권한 관리 대상을 사용합니다.</h2>
|
<h2>VPD와 동일한 권한 관리 대상을 사용합니다.</h2>
|
||||||
<p class="section-subtitle">사용자·그룹·역할·행 규칙·TAG 규칙은 한 권한 화면에서 관리합니다.</p>
|
<p class="section-subtitle">사용자·그룹·역할·행 규칙·TAG 규칙은 한 권한 화면에서 관리합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn btn-sm rw-btn-primary" href="/permissions">권한 규칙 열기</a>
|
<div class="d-flex gap-2">
|
||||||
|
<a class="btn btn-sm rw-btn-secondary" href="/permissions">권한 규칙 열기</a>
|
||||||
|
<a class="btn btn-sm rw-btn-primary" href="/dds-provision">DDS 권한 반영</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="policy-apply-flow" aria-label="DDS 적용 계층">
|
<div class="policy-apply-flow" aria-label="DDS 적용 계층">
|
||||||
<span>사용자·그룹·역할</span><strong>→</strong>
|
<span>사용자·그룹·역할</span><strong>→</strong>
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
|||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.context.properties.bind.Bindable;
|
||||||
|
import org.springframework.boot.context.properties.bind.Binder;
|
||||||
|
import org.springframework.boot.context.properties.source.MapConfigurationPropertySource;
|
||||||
|
|
||||||
class DdsPropertiesTest {
|
class DdsPropertiesTest {
|
||||||
|
|
||||||
@@ -33,4 +36,24 @@ class DdsPropertiesTest {
|
|||||||
"jdbc:test", Duration.ofSeconds(5), "ADMIN.VIEW;DROP", "ADMIN.VIEW", Map.of()
|
"jdbc:test", Duration.ofSeconds(5), "ADMIN.VIEW;DROP", "ADMIN.VIEW", Map.of()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bindsApplicationUserAndDataRoleForDdsProvisioning() {
|
||||||
|
var source = new MapConfigurationPropertySource(Map.of(
|
||||||
|
"dds.users.my.label", "MY",
|
||||||
|
"dds.users.my.description", "source",
|
||||||
|
"dds.users.my.username", "ddsuser_my",
|
||||||
|
"dds.users.my.password", "secret",
|
||||||
|
"dds.users.my.application-user-id", "101",
|
||||||
|
"dds.users.my.data-role", "dds_demo_my_role"
|
||||||
|
));
|
||||||
|
|
||||||
|
var properties = new Binder(source)
|
||||||
|
.bind("dds", Bindable.of(DdsProperties.class))
|
||||||
|
.orElseThrow(() -> new AssertionError("dds properties did not bind"));
|
||||||
|
|
||||||
|
assertEquals(101L, properties.users().get("my").applicationUserId());
|
||||||
|
assertEquals("dds_demo_my_role", properties.users().get("my").dataRole());
|
||||||
|
assertTrue(properties.users().get("my").mapped());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ for _ in {1..30}; do
|
|||||||
done
|
done
|
||||||
"$APP_DIR/status.sh"
|
"$APP_DIR/status.sh"
|
||||||
curl -sS -o /tmp/dds-backoffice-login.html -w 'login=%{http_code}\n' "http://127.0.0.1:${PORT}/login"
|
curl -sS -o /tmp/dds-backoffice-login.html -w 'login=%{http_code}\n' "http://127.0.0.1:${PORT}/login"
|
||||||
for path in / /users /groups /roles /permissions /effective-matrix /vpd-policies /vector-knowledge /dds; do
|
for path in / /users /groups /roles /permissions /effective-matrix /vpd-policies /dds-provision /vector-knowledge /dds; do
|
||||||
curl -sS --connect-timeout 3 --max-time 20 -u "${BACKOFFICE_ADMIN_USER:-}:${BACKOFFICE_ADMIN_PASSWORD:-}" \
|
curl -sS --connect-timeout 3 --max-time 20 -u "${BACKOFFICE_ADMIN_USER:-}:${BACKOFFICE_ADMIN_PASSWORD:-}" \
|
||||||
-o "/tmp/dds-backoffice-check-${path#/}.html" \
|
-o "/tmp/dds-backoffice-check-${path#/}.html" \
|
||||||
-w "${path}=%{http_code}\n" "http://127.0.0.1:${PORT}${path}" || true
|
-w "${path}=%{http_code}\n" "http://127.0.0.1:${PORT}${path}" || true
|
||||||
|
|||||||
@@ -42,6 +42,21 @@ PROMPT === 2. Replacing DATA GRANTs for the four demo identities ===
|
|||||||
-- The tag expression is deliberately an OR expression. Adding another
|
-- The tag expression is deliberately an OR expression. Adding another
|
||||||
-- allowed tag means replacing the corresponding grant expression, not
|
-- allowed tag means replacing the corresponding grant expression, not
|
||||||
-- writing a second conflicting policy.
|
-- writing a second conflicting policy.
|
||||||
|
BEGIN
|
||||||
|
EXECUTE IMMEDIATE 'DROP DATA GRANT admin.dds_demo_my_vector_grant';
|
||||||
|
EXCEPTION WHEN OTHERS THEN NULL;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
BEGIN
|
||||||
|
EXECUTE IMMEDIATE 'DROP DATA GRANT admin.dds_demo_pg_vector_grant';
|
||||||
|
EXCEPTION WHEN OTHERS THEN NULL;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
BEGIN
|
||||||
|
EXECUTE IMMEDIATE 'DROP DATA GRANT admin.dds_demo_both_vector_grant';
|
||||||
|
EXCEPTION WHEN OTHERS THEN NULL;
|
||||||
|
END;
|
||||||
|
/
|
||||||
CREATE OR REPLACE DATA GRANT admin.dds_demo_my_vector_grant
|
CREATE OR REPLACE DATA GRANT admin.dds_demo_my_vector_grant
|
||||||
AS SELECT
|
AS SELECT
|
||||||
ON admin.cb_dds_vector_search_documents
|
ON admin.cb_dds_vector_search_documents
|
||||||
@@ -62,7 +77,71 @@ CREATE OR REPLACE DATA GRANT admin.dds_demo_both_vector_grant
|
|||||||
|
|
||||||
-- dds_demo_none_role intentionally receives no grant (default deny).
|
-- dds_demo_none_role intentionally receives no grant (default deny).
|
||||||
|
|
||||||
PROMPT === 3. Vector object inventory ===
|
PROMPT === 3. Registering the vector object in the common application permission model ===
|
||||||
|
-- The application permission tables remain the source of truth. These rows
|
||||||
|
-- make the standalone DDS example use the same role/TAG scenario as VPD:
|
||||||
|
-- one ALLOW permission per role and multiple TAG rules combined with OR.
|
||||||
|
-- The backoffice's DDS publish screen compiles these rows into the grants
|
||||||
|
-- above (and excludes the protected EMBEDDING column by default).
|
||||||
|
DECLARE
|
||||||
|
PROCEDURE ensure_vector_permission(
|
||||||
|
p_role_id IN NUMBER,
|
||||||
|
p_tag_list IN SYS.ODCIVARCHAR2LIST
|
||||||
|
) IS
|
||||||
|
v_perm_id NUMBER;
|
||||||
|
v_rule_id NUMBER;
|
||||||
|
v_tag VARCHAR2(100);
|
||||||
|
BEGIN
|
||||||
|
BEGIN
|
||||||
|
SELECT perm_id
|
||||||
|
INTO v_perm_id
|
||||||
|
FROM cb_permission
|
||||||
|
WHERE role_id = p_role_id
|
||||||
|
AND target_name = 'CB_VECTOR_SEARCH_DOCUMENTS'
|
||||||
|
AND action_name = 'SELECT'
|
||||||
|
AND permission_effect = 'ALLOW'
|
||||||
|
AND ROWNUM = 1;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN NO_DATA_FOUND THEN
|
||||||
|
SELECT NVL(MAX(perm_id), 0) + 1 INTO v_perm_id FROM cb_permission;
|
||||||
|
INSERT INTO cb_permission (
|
||||||
|
perm_id, role_id, target_name, action_name, permission_effect
|
||||||
|
) VALUES (
|
||||||
|
v_perm_id, p_role_id, 'CB_VECTOR_SEARCH_DOCUMENTS', 'SELECT', 'ALLOW'
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
|
||||||
|
FOR i IN 1 .. p_tag_list.COUNT LOOP
|
||||||
|
v_tag := UPPER(TRIM(p_tag_list(i)));
|
||||||
|
BEGIN
|
||||||
|
SELECT rule_id
|
||||||
|
INTO v_rule_id
|
||||||
|
FROM cb_permission_rule
|
||||||
|
WHERE perm_id = v_perm_id
|
||||||
|
AND rule_column = 'TECH_TAG'
|
||||||
|
AND rule_type = 'TAG'
|
||||||
|
AND rule_value = v_tag
|
||||||
|
AND ROWNUM = 1;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN NO_DATA_FOUND THEN
|
||||||
|
SELECT NVL(MAX(rule_id), 0) + 1 INTO v_rule_id FROM cb_permission_rule;
|
||||||
|
INSERT INTO cb_permission_rule (
|
||||||
|
rule_id, perm_id, rule_column, rule_type, rule_value
|
||||||
|
) VALUES (
|
||||||
|
v_rule_id, v_perm_id, 'TECH_TAG', 'TAG', v_tag
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
BEGIN
|
||||||
|
ensure_vector_permission(10, SYS.ODCIVARCHAR2LIST('SPRING_BOOT', 'ORDS'));
|
||||||
|
ensure_vector_permission(20, SYS.ODCIVARCHAR2LIST('ORACLE_VPD', 'ORACLE_DDS'));
|
||||||
|
ensure_vector_permission(30, SYS.ODCIVARCHAR2LIST(
|
||||||
|
'SPRING_BOOT', 'ORDS', 'ORACLE_VPD', 'ORACLE_DDS', 'MCP'));
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
PROMPT === 4. Vector object inventory ===
|
||||||
SELECT 'DDS_VECTOR_OBJECT' AS item,
|
SELECT 'DDS_VECTOR_OBJECT' AS item,
|
||||||
'ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS' AS value
|
'ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS' AS value
|
||||||
FROM dual;
|
FROM dual;
|
||||||
|
|||||||
Reference in New Issue
Block a user