-
Notifications
You must be signed in to change notification settings - Fork 0
Feature/#12 - SwiftData 기반 Secret CRUD 및 보안 계층 구성 #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
dlguszoo
wants to merge
61
commits into
develop
Choose a base branch
from
feature/#12/Secret_ORM
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
61 commits
Select commit
Hold shift + click to select a range
cfb1ae6
[#12] bugfix: Secret 생성자 오류 수정
dlguszoo 5782af9
[#12] feat: 모듈간 dependency 설정
dlguszoo 8f4d12e
[#12] feat: Secret, Metadata, Payload entity 구현
dlguszoo b321a55
[#12] delete: DVDomain project 파일 내의 test 삭제
dlguszoo a1c20f1
[#12] feat: SecretRepository, Error case 구현
dlguszoo b946857
[#12] feat: SecretPatch, SecretQuery 구현
dlguszoo 64e2e6f
[#12] feat: SecretCryptoService 포로토콜 및 Error 구현
dlguszoo 3169653
[#12] fix: SecretMetadata, Payload Entity에서 id 제거
dlguszoo 6ecf9d7
[#12] feat: PayloadData 프로토콜 및 secret 종류별 구현
dlguszoo 7b0f66a
[#12] feat: Metadata 프로토콜 및 Secret 유형별 구현
dlguszoo ebd2a64
[#12] feat: Secret (생성, 삭제, 조회, 수정) 관련 UseCase 인터페이스 정의
dlguszoo a967188
[#12] feat: Secret UseCase 구현
dlguszoo 5c4638c
[#12] feat: Secret create에 필요한 input 정보 표현 draft 구현
dlguszoo 5b4ffee
[#12] feat: SecretUseCaseError 정의 및 mapping 함수 구현
dlguszoo cc06960
[#12] feat: Secret UseCase에서의 공통 로직 helper 구현
dlguszoo 7a18bfb
[#12] feat: Secret, SecretPayload, SecretMetadata에 extension으로 Entity…
dlguszoo 99e0136
[#12] feat: modelContainer 구성하는 LocalStorage 구현
dlguszoo 8f0fd93
[#12] feat: 기본 Secret(payload, metadata 포함) CRUD 메서드 구현한 SecretReposi…
dlguszoo 341e9a4
[#12] feat: SecretFetchDescriptorBuilder, InMemorySecretQueryFilter 구현
dlguszoo aec73b7
[#12] chore: Domain 모듈 Service 재폴더링
dlguszoo 582794c
[#12] feat: payload, metadata JSONCoder 구현
dlguszoo 4c2c8a2
[#12] feat: SecretCryptoServiceImpl 구현
dlguszoo e61f60b
[#12] feat: 키체인 key 접근 메서드 담은 KeychainKeyStore 구현
dlguszoo f1ea881
[#12] fix: SecretRepositoryImpl에서 public 생성자 제거
dlguszoo 6e619be
[#12] feat: Secret 생성/조회 demo view 구현
dlguszoo d6596d6
[#12] feat: UserAuthenticationService protocol, 구현체, error 구현
dlguszoo 6de4ec8
[#12] feat: payload reveal시 authenticate 메서드 거침
dlguszoo 137a984
[#12] refactor: KeychainKeyStore 중복 코드 처리 및. keychainFailure 에러케이스 추가
dlguszoo e69a414
[#12] feat: ContentView의 SecretUseCaseDemoView에서 받는 useCase에 authenti…
dlguszoo 9ab75e8
[#12] fix: 인증 후 조회/복호화 순서 조정
dlguszoo aefd684
[#12] bugfix: Secret 생성자 오류 수정
dlguszoo e80a794
[#12] feat: 모듈간 dependency 설정
dlguszoo 8e705d8
[#12] feat: Secret, Metadata, Payload entity 구현
dlguszoo 35ee0fc
[#12] delete: DVDomain project 파일 내의 test 삭제
dlguszoo 8b5f79f
[#12] feat: SecretRepository, Error case 구현
dlguszoo e79977c
[#12] feat: SecretPatch, SecretQuery 구현
dlguszoo f478bcf
[#12] feat: SecretCryptoService 포로토콜 및 Error 구현
dlguszoo bd76527
[#12] fix: SecretMetadata, Payload Entity에서 id 제거
dlguszoo b2dfd41
[#12] feat: PayloadData 프로토콜 및 secret 종류별 구현
dlguszoo 2d3a775
[#12] feat: Metadata 프로토콜 및 Secret 유형별 구현
dlguszoo 8601c2c
[#12] feat: Secret (생성, 삭제, 조회, 수정) 관련 UseCase 인터페이스 정의
dlguszoo 0003d5f
[#12] feat: Secret UseCase 구현
dlguszoo 6e51543
[#12] feat: Secret create에 필요한 input 정보 표현 draft 구현
dlguszoo da4158f
[#12] feat: SecretUseCaseError 정의 및 mapping 함수 구현
dlguszoo 8aa720d
[#12] feat: Secret UseCase에서의 공통 로직 helper 구현
dlguszoo aa24bf0
[#12] feat: Secret, SecretPayload, SecretMetadata에 extension으로 Entity…
dlguszoo 8b627c3
[#12] feat: modelContainer 구성하는 LocalStorage 구현
dlguszoo c24fa5c
[#12] feat: 기본 Secret(payload, metadata 포함) CRUD 메서드 구현한 SecretReposi…
dlguszoo 4114192
[#12] feat: SecretFetchDescriptorBuilder, InMemorySecretQueryFilter 구현
dlguszoo 21c8ad5
[#12] chore: Domain 모듈 Service 재폴더링
dlguszoo fbdbcad
[#12] feat: payload, metadata JSONCoder 구현
dlguszoo 9950a06
[#12] feat: SecretCryptoServiceImpl 구현
dlguszoo 49b1547
[#12] feat: 키체인 key 접근 메서드 담은 KeychainKeyStore 구현
dlguszoo ae20f04
[#12] fix: SecretRepositoryImpl에서 public 생성자 제거
dlguszoo 5edb35f
[#12] feat: Secret 생성/조회 demo view 구현
dlguszoo ef5a452
[#12] feat: UserAuthenticationService protocol, 구현체, error 구현
dlguszoo 5b800ba
[#12] feat: payload reveal시 authenticate 메서드 거침
dlguszoo 7091064
[#12] refactor: KeychainKeyStore 중복 코드 처리 및. keychainFailure 에러케이스 추가
dlguszoo 5c87dea
[#12] feat: ContentView의 SecretUseCaseDemoView에서 받는 useCase에 authenti…
dlguszoo 9faeab4
[#12] fix: 인증 후 조회/복호화 순서 조정
dlguszoo 96b2414
Merge branch 'feature/#12/Secret_ORM' of https://github.com/DevaultPr…
dlguszoo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
35 changes: 35 additions & 0 deletions
35
Projects/DVData/Sources/RepositoryImpl/Secret/InMemorySecretQueryFilter.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| // Copyright © 2026 Devault. All rights reserved | ||
|
|
||
| import DVDomain | ||
| import Foundation | ||
|
|
||
| /// SwiftData FetchDescriptor로 처리하기 애매한 조건을 Domain Secret 배열에서 후처리하는 필터. 현재는 searchText만 담당 | ||
| enum InMemorySecretQueryFilter { | ||
| static func apply(_ query: SecretQuery, to secrets: [DVDomain.Secret]) -> [DVDomain.Secret] { | ||
| secrets.filter { matchesSearchText(query.searchText, secret: $0) } | ||
| } | ||
|
|
||
| private static func matchesSearchText(_ searchText: String?, secret: DVDomain.Secret) -> Bool { | ||
| guard let searchText else { | ||
| return true | ||
| } | ||
|
|
||
| let keyword = searchText.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| guard !keyword.isEmpty else { | ||
| return true | ||
| } | ||
|
|
||
| let fields = [ | ||
| secret.name, | ||
| secret.secretType, | ||
| secret.subType, | ||
| secret.service, | ||
| secret.environment, | ||
| secret.memo, | ||
| ] | ||
|
|
||
| return fields.contains { | ||
| $0?.localizedCaseInsensitiveContains(keyword) == true | ||
| } | ||
| } | ||
| } |
102 changes: 102 additions & 0 deletions
102
Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| // Copyright © 2026 Devault. All rights reserved | ||
|
|
||
| import DVDomain | ||
| import Foundation | ||
| import SwiftData | ||
|
|
||
| /// Domain의 SecretQuery를 SwiftData의 FetchDescriptor<SwiftDataModel.Secret>로 바꾸는 타입 | ||
| enum SecretFetchDescriptorBuilder { | ||
| static func make(from query: SecretQuery) -> FetchDescriptor<SwiftDataModel.Secret> { | ||
| var descriptor = FetchDescriptor<SwiftDataModel.Secret>( | ||
| predicate: predicate(from: query), | ||
| sortBy: sortDescriptors(from: query.sort) | ||
| ) | ||
| descriptor.includePendingChanges = true | ||
| return descriptor | ||
| } | ||
|
|
||
| private static func predicate(from query: SecretQuery) -> Predicate<SwiftDataModel.Secret> { | ||
| let hasSecretType = !(query.secretType?.isEmpty ?? true) | ||
| let secretType = query.secretType ?? "" | ||
| let hasService = !(query.service?.isEmpty ?? true) | ||
| let service = query.service ?? "" | ||
| let hasEnvironment = !(query.environment?.isEmpty ?? true) | ||
| let environment = query.environment ?? "" | ||
|
|
||
| switch query.collection { | ||
| case .all: | ||
| return #Predicate<SwiftDataModel.Secret> { secret in | ||
| secret.deletedAt == nil && | ||
| (!hasSecretType || secret.secretType == secretType) && | ||
| (!hasService || secret.service == service) && | ||
| (!hasEnvironment || secret.environment == environment) | ||
| } | ||
| case .liked: | ||
| return #Predicate<SwiftDataModel.Secret> { secret in | ||
| secret.deletedAt == nil && | ||
| secret.liked && | ||
| (!hasSecretType || secret.secretType == secretType) && | ||
| (!hasService || secret.service == service) && | ||
| (!hasEnvironment || secret.environment == environment) | ||
| } | ||
| case let .expired(referenceDate): | ||
| return #Predicate<SwiftDataModel.Secret> { secret in | ||
| secret.deletedAt == nil && | ||
| secret.expiresAt != nil && | ||
| secret.expiresAt! < referenceDate && | ||
| (!hasSecretType || secret.secretType == secretType) && | ||
| (!hasService || secret.service == service) && | ||
| (!hasEnvironment || secret.environment == environment) | ||
| } | ||
| case .deleted: | ||
| return #Predicate<SwiftDataModel.Secret> { secret in | ||
| secret.deletedAt != nil && | ||
| (!hasSecretType || secret.secretType == secretType) && | ||
| (!hasService || secret.service == service) && | ||
| (!hasEnvironment || secret.environment == environment) | ||
| } | ||
| case let .project(projectID): | ||
| return #Predicate<SwiftDataModel.Secret> { secret in | ||
| secret.deletedAt == nil && | ||
| secret.projectLinks.contains { link in | ||
| link.project.id == projectID | ||
| } && | ||
| (!hasSecretType || secret.secretType == secretType) && | ||
| (!hasService || secret.service == service) && | ||
| (!hasEnvironment || secret.environment == environment) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private static func sortDescriptors( | ||
| from sort: SecretQuery.Sort | ||
| ) -> [SortDescriptor<SwiftDataModel.Secret>] { | ||
| switch sort { | ||
| case .recentlyAdded: | ||
| return [ | ||
| SortDescriptor(\.createdAt, order: .reverse), | ||
| SortDescriptor(\.updatedAt, order: .reverse), | ||
| ] | ||
| case .oldestFirst: | ||
| return [ | ||
| SortDescriptor(\.createdAt, order: .forward), | ||
| SortDescriptor(\.updatedAt, order: .forward), | ||
| ] | ||
| case .expiringSoon: | ||
| return [ | ||
| SortDescriptor(\.expiresAt, order: .forward), | ||
| SortDescriptor(\.updatedAt, order: .reverse), | ||
| ] | ||
| case .nameAscending: | ||
| return [ | ||
| SortDescriptor(\.name, order: .forward), | ||
| SortDescriptor(\.updatedAt, order: .reverse), | ||
| ] | ||
| case .nameDescending: | ||
| return [ | ||
| SortDescriptor(\.name, order: .reverse), | ||
| SortDescriptor(\.updatedAt, order: .reverse), | ||
| ] | ||
| } | ||
| } | ||
| } |
208 changes: 208 additions & 0 deletions
208
Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,208 @@ | ||
| // Copyright © 2026 Devault. All rights reserved | ||
|
|
||
| import DVDomain | ||
| import Foundation | ||
| import SwiftData | ||
|
|
||
| @ModelActor | ||
| public actor SecretRepositoryImpl: SecretRepository { | ||
| public func create(_ secret: DVDomain.Secret) async throws -> DVDomain.Secret { | ||
| do { | ||
| let localSecret = SwiftDataModel.Secret( | ||
| id: secret.id, | ||
| name: secret.name, | ||
| secretType: secret.secretType, | ||
| subType: secret.subType, | ||
| service: secret.service, | ||
| environment: secret.environment, | ||
| expiresAt: secret.expiresAt, | ||
| memo: secret.memo, | ||
| liked: secret.liked, | ||
| deletedAt: secret.deletedAt, | ||
| createdAt: secret.createdAt, | ||
| updatedAt: secret.updatedAt | ||
| ) | ||
|
|
||
| let localPayload = SwiftDataModel.SecretPayload( | ||
| encryptedData: secret.payload.encryptedData, | ||
| keyTag: secret.payload.keyTag, | ||
| schemaVersion: secret.payload.schemaVersion, | ||
| secret: localSecret | ||
| ) | ||
| localSecret.payload = localPayload | ||
|
|
||
| modelContext.insert(localSecret) | ||
| modelContext.insert(localPayload) | ||
|
|
||
| if let metadata = secret.metadata { | ||
| let localMetadata = SwiftDataModel.SecretMetadata( | ||
| metadataJSON: metadata.metadataJSON, | ||
| schemaVersion: metadata.schemaVersion, | ||
| secret: localSecret | ||
| ) | ||
| localSecret.metadata = localMetadata | ||
| modelContext.insert(localMetadata) | ||
| } | ||
|
|
||
| try modelContext.save() | ||
|
|
||
| return try localSecret.toDomain() | ||
| } catch let error as SecretRepositoryError { | ||
| throw error | ||
| } catch { | ||
| throw SecretRepositoryError.persistenceFailed | ||
| } | ||
| } | ||
|
|
||
| public func fetch(id: UUID) async throws -> DVDomain.Secret? { | ||
| do { | ||
| guard let localSecret = try fetchLocalSecret(id: id) else { | ||
| return nil | ||
| } | ||
| return try localSecret.toDomain() | ||
| } catch let error as SecretRepositoryError { | ||
| throw error | ||
| } catch { | ||
| throw SecretRepositoryError.persistenceFailed | ||
| } | ||
| } | ||
|
|
||
| /// SecretFetchDescriptorBuilder로 원하는 조건으로 fetch 후 InMemorySecretQueryFilter로 searchText 보정 | ||
| public func fetch(_ query: SecretQuery) async throws -> [DVDomain.Secret] { | ||
| do { | ||
| let descriptor = SecretFetchDescriptorBuilder.make(from: query) | ||
| let localSecrets = try modelContext.fetch(descriptor) | ||
| let domainSecrets = try localSecrets.map { try $0.toDomain() } | ||
| return InMemorySecretQueryFilter.apply(query, to: domainSecrets) | ||
| } catch let error as SecretRepositoryError { | ||
| throw error | ||
| } catch { | ||
| throw SecretRepositoryError.persistenceFailed | ||
| } | ||
| } | ||
|
|
||
| /// SecretPatch 적용하여 update | ||
| public func patch(id: UUID, with patch: SecretPatch) async throws -> DVDomain.Secret { | ||
| do { | ||
| guard let localSecret = try fetchLocalSecret(id: id) else { | ||
| throw SecretRepositoryError.notFound(id: id) | ||
| } | ||
|
|
||
| apply(patch, to: localSecret) | ||
| try modelContext.save() | ||
|
|
||
| return try localSecret.toDomain() | ||
| } catch let error as SecretRepositoryError { | ||
| throw error | ||
| } catch { | ||
| throw SecretRepositoryError.persistenceFailed | ||
| } | ||
| } | ||
|
|
||
| public func delete(id: UUID) async throws { | ||
| do { | ||
| guard let localSecret = try fetchLocalSecret(id: id) else { | ||
| throw SecretRepositoryError.notFound(id: id) | ||
| } | ||
|
|
||
| modelContext.delete(localSecret) | ||
| try modelContext.save() | ||
| } catch let error as SecretRepositoryError { | ||
| throw error | ||
| } catch { | ||
| throw SecretRepositoryError.persistenceFailed | ||
| } | ||
| } | ||
| } | ||
|
|
||
| extension SecretRepositoryImpl { | ||
| private func fetchLocalSecret(id: UUID) throws -> SwiftDataModel.Secret? { | ||
| var descriptor = FetchDescriptor<SwiftDataModel.Secret>( | ||
| predicate: #Predicate { $0.id == id } | ||
| ) | ||
| descriptor.fetchLimit = 1 | ||
| return try modelContext.fetch(descriptor).first | ||
| } | ||
|
|
||
| /// SecretPatch를 SwiftData model에 반영 | ||
| private func apply(_ patch: SecretPatch, to secret: SwiftDataModel.Secret) { | ||
| if case let .set(name) = patch.name { | ||
| secret.name = name | ||
| } | ||
| if case let .set(secretType) = patch.secretType { | ||
| secret.secretType = secretType | ||
| } | ||
| if case let .set(subType) = patch.subType { | ||
| secret.subType = subType | ||
| } | ||
| if case let .set(service) = patch.service { | ||
| secret.service = service | ||
| } | ||
| if case let .set(environment) = patch.environment { | ||
| secret.environment = environment | ||
| } | ||
| if case let .set(expiresAt) = patch.expiresAt { | ||
| secret.expiresAt = expiresAt | ||
| } | ||
| if case let .set(memo) = patch.memo { | ||
| secret.memo = memo | ||
| } | ||
| if case let .set(liked) = patch.liked { | ||
| secret.liked = liked | ||
| } | ||
| if case let .set(deletedAt) = patch.deletedAt { | ||
| secret.deletedAt = deletedAt | ||
| } | ||
| if case let .set(updatedAt) = patch.updatedAt { | ||
| secret.updatedAt = updatedAt | ||
| } | ||
| if case let .set(payload) = patch.payload { | ||
| apply(payload, to: secret) | ||
| } | ||
| if case let .set(metadata) = patch.metadata { | ||
| apply(metadata, to: secret) | ||
| } | ||
| } | ||
|
|
||
| /// payload가 있으면 업데이트하고, 없으면 새 payload를 만든다. | ||
| private func apply(_ payload: DVDomain.SecretPayload, to secret: SwiftDataModel.Secret) { | ||
| if let localPayload = secret.payload { | ||
| localPayload.encryptedData = payload.encryptedData | ||
| localPayload.keyTag = payload.keyTag | ||
| localPayload.schemaVersion = payload.schemaVersion | ||
| } else { | ||
| let localPayload = SwiftDataModel.SecretPayload( | ||
| encryptedData: payload.encryptedData, | ||
| keyTag: payload.keyTag, | ||
| schemaVersion: payload.schemaVersion, | ||
| secret: secret | ||
| ) | ||
| secret.payload = localPayload | ||
| modelContext.insert(localPayload) | ||
| } | ||
| } | ||
|
|
||
| /// metadata가 nil이면 기존 metadata를 삭제하고, 값이 있으면 업데이트 또는 생성한다. | ||
| private func apply(_ metadata: DVDomain.SecretMetadata?, to secret: SwiftDataModel.Secret) { | ||
| guard let metadata else { | ||
| if let localMetadata = secret.metadata { | ||
| modelContext.delete(localMetadata) | ||
| } | ||
| secret.metadata = nil | ||
| return | ||
| } | ||
|
|
||
| if let localMetadata = secret.metadata { | ||
| localMetadata.metadataJSON = metadata.metadataJSON | ||
| localMetadata.schemaVersion = metadata.schemaVersion | ||
| } else { | ||
| let localMetadata = SwiftDataModel.SecretMetadata( | ||
| metadataJSON: metadata.metadataJSON, | ||
| schemaVersion: metadata.schemaVersion, | ||
| secret: secret | ||
| ) | ||
| secret.metadata = localMetadata | ||
| modelContext.insert(localMetadata) | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SecretRepositoryImpl.create에서 duplicate ID 검사 누락 [MEDIUM] (SecretRepositoryImpl.swift:181-227)
SecretRepositoryError.duplicateID(id:)가 정의되어 있지만 어디서도 throw 안 합니다. 호출자(또는 재시도 로직)가 같은 ID로 두 번 create 하면 SwiftData가 묵묵히 두 row를 만들거나 비특정 오류를 던질 수 있어요.