Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 75 additions & 21 deletions src/Api/Vault/Controllers/CiphersController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -952,8 +952,8 @@ public async Task<CipherResponseModel> PostShare(Guid id, [FromBody] CipherShare
return await PutShare(id, model);
}

[HttpPut("{id}/collections")]
public async Task<CipherDetailsResponseModel> PutCollections(Guid id, [FromBody] CipherCollectionsRequestModel model)
[HttpPut("{id}/shared-folders")]
public async Task<CipherDetailsResponseModel> PutSharedFolders(Guid id, [FromBody] CipherSharedFoldersRequestModel model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
var cipher = await GetByIdAsync(id, user.Id);
Expand All @@ -964,23 +964,36 @@ public async Task<CipherDetailsResponseModel> PutCollections(Guid id, [FromBody]
}

await _cipherService.SaveCollectionsAsync(cipher,
model.CollectionIds.Select(c => new Guid(c)), user.Id, false);
model.SharedFolderIds.Select(c => new Guid(c)), user.Id, false);

var updatedCipher = await GetByIdAsync(id, user.Id);
var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(user.Id, id);

return await BuildWriteReturnDetailsResponseAsync(updatedCipher, user, collectionCiphers);
var response = await BuildWriteReturnDetailsResponseAsync(updatedCipher, user, collectionCiphers);
response.UseSharedFolderTerms();
return response;
}

[HttpPut("{id}/collections")]
[Obsolete("Replaced by /shared-folders route; retained for backwards compatibility")]
public async Task<CipherDetailsResponseModel> PutCollections(Guid id, [FromBody] CipherCollectionsRequestModel model)
{
var response = await PutSharedFolders(id, ToSharedFoldersRequest(model));
response.UseCollectionTerms();
return response;
}

[HttpPost("{id}/collections")]
[Obsolete("This endpoint is deprecated. Use PUT method instead.")]
public async Task<CipherDetailsResponseModel> PostCollections(Guid id, [FromBody] CipherCollectionsRequestModel model)
{
return await PutCollections(id, model);
var response = await PutSharedFolders(id, ToSharedFoldersRequest(model));
response.UseCollectionTerms();
return response;
}

[HttpPut("{id}/collections_v2")]
public async Task<OptionalCipherDetailsResponseModel> PutCollections_vNext(Guid id, [FromBody] CipherCollectionsRequestModel model)
[HttpPut("{id}/shared-folders_v2")]
public async Task<OptionalCipherDetailsResponseModel> PutSharedFolders_vNext(Guid id, [FromBody] CipherSharedFoldersRequestModel model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
var cipher = await GetByIdAsync(id, user.Id);
Expand All @@ -991,7 +1004,7 @@ public async Task<OptionalCipherDetailsResponseModel> PutCollections_vNext(Guid
}

await _cipherService.SaveCollectionsAsync(cipher,
model.CollectionIds.Select(c => new Guid(c)), user.Id, false);
model.SharedFolderIds.Select(c => new Guid(c)), user.Id, false);

var updatedCipher = await GetByIdAsync(id, user.Id);
var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(user.Id, id);
Expand All @@ -1004,18 +1017,30 @@ await _cipherService.SaveCollectionsAsync(cipher,
? null
: await BuildWriteReturnDetailsResponseAsync(updatedCipher, user, collectionCiphers)
};
response.Cipher?.UseSharedFolderTerms();
return response;
}

[HttpPut("{id}/collections_v2")]
[Obsolete("Replaced by /shared-folders_v2 route; retained for backwards compatibility")]
public async Task<OptionalCipherDetailsResponseModel> PutCollections_vNext(Guid id, [FromBody] CipherCollectionsRequestModel model)
{
var response = await PutSharedFolders_vNext(id, ToSharedFoldersRequest(model));
response.Cipher?.UseCollectionTerms();
return response;
}

[HttpPost("{id}/collections_v2")]
[Obsolete("This endpoint is deprecated. Use PUT method instead.")]
public async Task<OptionalCipherDetailsResponseModel> PostCollections_vNext(Guid id, [FromBody] CipherCollectionsRequestModel model)
{
return await PutCollections_vNext(id, model);
var response = await PutSharedFolders_vNext(id, ToSharedFoldersRequest(model));
response.Cipher?.UseCollectionTerms();
return response;
}

[HttpPut("{id}/collections-admin")]
public async Task<CipherMiniDetailsResponseModel> PutCollectionsAdmin(string id, [FromBody] CipherCollectionsRequestModel model)
[HttpPut("{id}/shared-folders-admin")]
public async Task<CipherMiniDetailsResponseModel> PutSharedFoldersAdmin(string id, [FromBody] CipherSharedFoldersRequestModel model)
{
var userId = _userService.GetProperUserId(User).Value;
var cipher = await _cipherRepository.GetOrganizationDetailsByIdAsync(new Guid(id));
Expand All @@ -1026,7 +1051,7 @@ public async Task<CipherMiniDetailsResponseModel> PutCollectionsAdmin(string id,
throw new NotFoundException();
}

var collectionIds = model.CollectionIds.Select(c => new Guid(c)).ToList();
var collectionIds = model.SharedFolderIds.Select(c => new Guid(c)).ToList();

// In V1, we still need to check if the user can edit the collections they're submitting
// This should only happen for unassigned ciphers (otherwise restricted admins would use the normal collections endpoint)
Expand All @@ -1041,25 +1066,38 @@ public async Task<CipherMiniDetailsResponseModel> PutCollectionsAdmin(string id,
var collectionCiphersGroupDict = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(s => s.Key);

var access = await AuthorizeAdminWriteReturnOrThrowAsync(userId, cipher.OrganizationId.Value, cipher);
return CipherMiniDetailsResponseModel.From(access, cipher, _globalSettings,
var response = CipherMiniDetailsResponseModel.From(access, cipher, _globalSettings,
collectionCiphersGroupDict, cipher.OrganizationUseTotp);
response.UseSharedFolderTerms();
return response;
}

[HttpPut("{id}/collections-admin")]
[Obsolete("Replaced by /shared-folders-admin route; retained for backwards compatibility")]
public async Task<CipherMiniDetailsResponseModel> PutCollectionsAdmin(string id, [FromBody] CipherCollectionsRequestModel model)
{
var response = await PutSharedFoldersAdmin(id, ToSharedFoldersRequest(model));
response.UseCollectionTerms();
return response;
}

[HttpPost("{id}/collections-admin")]
[Obsolete("This endpoint is deprecated. Use PUT method instead.")]
public async Task<CipherMiniDetailsResponseModel> PostCollectionsAdmin(string id, [FromBody] CipherCollectionsRequestModel model)
{
return await PutCollectionsAdmin(id, model);
var response = await PutSharedFoldersAdmin(id, ToSharedFoldersRequest(model));
response.UseCollectionTerms();
return response;
}

[HttpPost("bulk-collections")]
public async Task PostBulkCollections([FromBody] CipherBulkUpdateCollectionsRequestModel model)
[HttpPost("bulk-shared-folders")]
public async Task PostBulkSharedFolders([FromBody] CipherBulkUpdateSharedFoldersRequestModel model)
{
var userId = _userService.GetProperUserId(User).Value;
await _cipherService.ValidateBulkCollectionAssignmentAsync(model.CollectionIds, model.CipherIds, userId, model.RemoveCollections);
await _cipherService.ValidateBulkCollectionAssignmentAsync(model.SharedFolderIds, model.CipherIds, userId, model.RemoveSharedFolders);

if (!await CanModifyCipherCollectionsAsync(model.OrganizationId, model.CipherIds) ||
!await CanEditItemsInCollections(model.OrganizationId, model.CollectionIds))
!await CanEditItemsInCollections(model.OrganizationId, model.SharedFolderIds))
{
throw new NotFoundException();
}
Expand All @@ -1076,16 +1114,32 @@ public async Task PostBulkCollections([FromBody] CipherBulkUpdateCollectionsRequ
await _cipherLeaseGate.EnsureCanMutateManyAsync(userId, ciphers);
}

if (model.RemoveCollections)
if (model.RemoveSharedFolders)
{
await _collectionCipherRepository.RemoveCollectionsForManyCiphersAsync(model.OrganizationId, model.CipherIds, model.CollectionIds);
await _collectionCipherRepository.RemoveCollectionsForManyCiphersAsync(model.OrganizationId, model.CipherIds, model.SharedFolderIds);
}
else
{
await _collectionCipherRepository.AddCollectionsForManyCiphersAsync(model.OrganizationId, model.CipherIds, model.CollectionIds);
await _collectionCipherRepository.AddCollectionsForManyCiphersAsync(model.OrganizationId, model.CipherIds, model.SharedFolderIds);
}
}

[HttpPost("bulk-collections")]
[Obsolete("Replaced by /bulk-shared-folders route; retained for backwards compatibility")]
public async Task PostBulkCollections([FromBody] CipherBulkUpdateCollectionsRequestModel model)
{
await PostBulkSharedFolders(new CipherBulkUpdateSharedFoldersRequestModel
{
OrganizationId = model.OrganizationId,
CipherIds = model.CipherIds,
SharedFolderIds = model.CollectionIds,
RemoveSharedFolders = model.RemoveCollections,
});
}

private static CipherSharedFoldersRequestModel ToSharedFoldersRequest(CipherCollectionsRequestModel model) =>
new() { SharedFolderIds = model.CollectionIds };

[HttpPut("{id}/archive")]
public async Task<CipherResponseModel> PutArchive(Guid id)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
ο»Ώ// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
Comment on lines +1 to +2

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The FIXME comment above CipherBulkUpdateSharedFoldersRequestModel marks null-safety work as unfinished and leaves that deferral in production code.

Suggested change
ο»Ώ// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
ο»Ώ#nullable disable
Details

✨ AI Reasoning
​The comment marks null-safety work as unfinished. It has no ticket reference or complete handoff plan, so it is a lingering deferral marker in production code.

Reply on this comment to give feedback or ignore the issue.
More info

@aaronjordan aaronjordan Nov 6, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This effectively clones the existing 'collections' file, so I'm not concerned about the new FIXME.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

βœ… Ignored this finding.


namespace Bit.Api.Vault.Models.Request;

public class CipherBulkUpdateSharedFoldersRequestModel
{
public Guid OrganizationId { get; set; }

public IEnumerable<Guid> CipherIds { get; set; }

public IEnumerable<Guid> SharedFolderIds { get; set; }

/// <summary>
/// If true, the shared folders will be removed from the ciphers. Otherwise, they will be added.
/// </summary>
public bool RemoveSharedFolders { get; set; }
}
6 changes: 6 additions & 0 deletions src/Api/Vault/Models/Request/CipherRequestModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,12 @@ public class CipherCollectionsRequestModel
public IEnumerable<string> CollectionIds { get; set; }
}

public class CipherSharedFoldersRequestModel
{
[Required]
public IEnumerable<string> SharedFolderIds { get; set; }
}

public class CipherBulkArchiveRequestModel
{
[Required]
Expand Down
44 changes: 42 additions & 2 deletions src/Api/Vault/Models/Response/CipherResponseModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,27 @@ public static CipherDetailsResponseModel From(FullCipherAccess access, CipherDet
? new FullCipherDetailsResponseModel(access, cipher, user, organizationAbility, globalSettings, obj)
: new PartialCipherDetailsResponseModel(cipher, user, organizationAbility, obj);

public IEnumerable<Guid> CollectionIds { get; set; }
/// <summary>Legacy term. Omitted from the payload when <see cref="SharedFolderIds"/> is in use.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IEnumerable<Guid>? CollectionIds { get; set; }

/// <summary>Shared-folder term for <see cref="CollectionIds"/>. Only populated by <see cref="UseSharedFolderTerms"/>.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IEnumerable<Guid>? SharedFolderIds { get; set; }

/// <summary>Moves the collection ids to <see cref="SharedFolderIds"/> so the payload uses shared-folder terms.</summary>
public void UseSharedFolderTerms()
{
SharedFolderIds ??= CollectionIds;
CollectionIds = null;
}

/// <summary>Reverses <see cref="UseSharedFolderTerms"/> for legacy (collections) endpoints.</summary>
public void UseCollectionTerms()
{
CollectionIds ??= SharedFolderIds;
SharedFolderIds = null;
}
}

/// <summary>The reduced-data counterpart of <see cref="CipherDetailsResponseModel"/>.</summary>
Expand Down Expand Up @@ -520,7 +540,27 @@ public static CipherMiniDetailsResponseModel From(FullCipherAccess access,
string obj = "cipherMiniDetails") =>
From(access, cipher, globalSettings, cipher.OrganizationUseTotp, obj);

public IEnumerable<Guid> CollectionIds { get; set; }
/// <summary>Legacy term. Omitted from the payload when <see cref="SharedFolderIds"/> is in use.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IEnumerable<Guid>? CollectionIds { get; set; }

/// <summary>Shared-folder term for <see cref="CollectionIds"/>. Only populated by <see cref="UseSharedFolderTerms"/>.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IEnumerable<Guid>? SharedFolderIds { get; set; }

/// <summary>Moves the collection ids to <see cref="SharedFolderIds"/> so the payload uses shared-folder terms.</summary>
public void UseSharedFolderTerms()
{
SharedFolderIds ??= CollectionIds;
CollectionIds = null;
}

/// <summary>Reverses <see cref="UseSharedFolderTerms"/> for legacy (collections) endpoints.</summary>
public void UseCollectionTerms()
{
CollectionIds ??= SharedFolderIds;
SharedFolderIds = null;
}
}

/// <summary>The reduced-data counterpart of <see cref="CipherMiniDetailsResponseModel"/>.</summary>
Expand Down
Loading
Loading