Skip to content

Commit 44128bb

Browse files
Implement stored credential unlock where allowed by the credential, add optional expiry info
1 parent 473eecb commit 44128bb

File tree

9 files changed

+180
-13
lines changed

9 files changed

+180
-13
lines changed

src/Certify.CLI/CertifyCLI.StoredCredentials.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ internal async Task UpdateStoredCredential(string[] args)
2525
var cred = new StoredCredential
2626
{
2727
StorageKey = storageKey,
28-
DateCreated = DateTime.UtcNow,
28+
DateCreated = DateTimeOffset.UtcNow,
2929
ProviderType = credentialType,
3030
Secret = secretValue,
3131
Title = title

src/Certify.Core/Management/CertifyManager/CertifyManager.ManagementHub.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,31 @@ public async Task<InstanceCommandResult> PerformHubCommandWithResult(InstanceCom
617617
var itemArg = args.FirstOrDefault(a => a.Key == "storageKey");
618618
val = await _credentialsManager.Delete(_itemManager, itemArg.Value);
619619
}
620+
else if (arg.CommandType == ManagementHubCommands.UnlockStoredCredential)
621+
{
622+
var args = JsonSerializer.Deserialize<KeyValuePair<string, string>[]>(arg.Value, JsonOptions.DefaultJsonSerializerOptions);
623+
var itemArg = args.FirstOrDefault(a => a.Key == "storageKey");
624+
var key = itemArg.Value;
625+
var cred = await _credentialsManager.GetCredential(key);
626+
if (cred.AllowUnlock)
627+
{
628+
var unlockedCredValue = await _credentialsManager.GetUnlockedCredential(key);
629+
if (unlockedCredValue != null)
630+
{
631+
632+
cred.Secret = unlockedCredValue;
633+
val = new StoredCredentialUnlockResult { IsSuccess = true, Result = cred };
634+
}
635+
else
636+
{
637+
val = null;
638+
}
639+
}
640+
else
641+
{
642+
val = new StoredCredentialUnlockResult { IsSuccess = false, Message = "This credential does not allow unlocking" };
643+
}
644+
}
620645
else if (arg.CommandType == ManagementHubCommands.GetChallengeProviders)
621646
{
622647
val = await Core.Management.Challenges.ChallengeProviders.GetChallengeAPIProviders();

src/Certify.Models/Config/StoredCredential.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,26 @@ public class StoredCredential
77
public string? ProviderType { get; set; } = string.Empty;
88
public string? Title { get; set; } = string.Empty;
99
public string? StorageKey { get; set; }
10-
public DateTime DateCreated { get; set; }
10+
public DateTimeOffset DateCreated { get; set; }
11+
12+
/// <summary>
13+
/// Optionally set expiry date for this credential, if not set we do not expect it to expire
14+
/// </summary>
15+
public DateTimeOffset? DateExpiry { get; set; }
1116

1217
/// <summary>
1318
/// Secret is only populated in the client when saving, the secret is not available to the UI
1419
/// </summary>
1520
public string? Secret { get; set; }
21+
22+
/// <summary>
23+
/// If true, this item can be unlocked for download or sharing to authorized consumers
24+
/// </summary>
25+
public bool AllowUnlock { get; set; }
26+
}
27+
28+
public class StoredCredentialUnlockResult : ActionResult<StoredCredential>
29+
{
30+
1631
}
1732
}

src/Certify.Models/Hub/AccessControlConfig.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ public static class StandardResourceActions
128128
public const string StoredCredentialUpdate = "storedcredential_update_action";
129129
public const string StoredCredentialDelete = "storedcredential_delete_action";
130130
public const string StoredCredentialList = "storedcredential_list_action";
131-
public const string StoredCredentialDownload = "storedcredential_consumer_action";
131+
public const string StoredCredentialReadSecret = "storedcredential_consumer_action";
132132

133133
public const string SecurityPrincipalList = "securityprincipal_list_action";
134134
public const string SecurityPrincipalAdd = "securityprincipal_add_action";
@@ -247,7 +247,7 @@ public static List<ResourceAction> GetStandardResourceActions()
247247
new(StandardResourceActions.StoredCredentialUpdate, "Update Stored Credential", ResourceTypes.StoredCredential),
248248
new(StandardResourceActions.StoredCredentialDelete, "Delete Stored Credential", ResourceTypes.StoredCredential),
249249
new(StandardResourceActions.StoredCredentialList, "List Stored Credentials", ResourceTypes.StoredCredential),
250-
new(StandardResourceActions.StoredCredentialDownload, "Fetch Decrypted Stored Credential", ResourceTypes.StoredCredential),
250+
new(StandardResourceActions.StoredCredentialReadSecret, "Fetch Decrypted Stored Credential", ResourceTypes.StoredCredential),
251251

252252
new(StandardResourceActions.SecurityPrincipalList, "List Security Principals", ResourceTypes.SecurityPrincipal),
253253
new(StandardResourceActions.SecurityPrincipalAdd, "Add New Security Principal", ResourceTypes.SecurityPrincipal),
@@ -425,7 +425,7 @@ public static List<ResourcePolicy> GetStandardPolicies()
425425
SecurityPermissionType = SecurityPermissionType.ALLOW,
426426
IsResourceSpecific = true,
427427
ResourceActions = [
428-
StandardResourceActions.StoredCredentialDownload
428+
StandardResourceActions.StoredCredentialReadSecret
429429
]
430430
},
431431
new() {

src/Certify.Models/Hub/ManagementHubMessages.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public class ManagementHubCommands
3737
public const string GetStoredCredentials = "GetStoredCredentials";
3838
public const string UpdateStoredCredential = "UpdateStoredCredential";
3939
public const string RemoveStoredCredential = "RemoveStoredCredential";
40+
public const string UnlockStoredCredential = "UnlockStoredCredential";
4041

4142
public const string GetChallengeProviders = "GetChallengeProviders";
4243
public const string GetDnsZones = "GetDnsZones";

src/Certify.Server/Certify.Server.Hub.Api.Client/Certify.Server.Hub.Api.Client.cs

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1487,7 +1487,7 @@ public virtual async System.Threading.Tasks.Task<AuthResponse> LoginAsync(AuthRe
14871487
}
14881488

14891489
/// <summary>
1490-
/// Refresh users current auth token
1490+
/// Refresh users current auth token using refresh token
14911491
/// </summary>
14921492
/// <returns>OK</returns>
14931493
/// <exception cref="ApiException">A server side error occurred.</exception>
@@ -1498,7 +1498,7 @@ public virtual System.Threading.Tasks.Task<AuthResponse> RefreshAsync(string ref
14981498

14991499
/// <param name="cancellationToken">A cancellation token that can be used by other objects or threads to receive notice of cancellation.</param>
15001500
/// <summary>
1501-
/// Refresh users current auth token
1501+
/// Refresh users current auth token using refresh token
15021502
/// </summary>
15031503
/// <returns>OK</returns>
15041504
/// <exception cref="ApiException">A server side error occurred.</exception>
@@ -3350,7 +3350,7 @@ public virtual async System.Threading.Tasks.Task<ActionResult> RemoveAcmeAccount
33503350
/// </summary>
33513351
/// <returns>OK</returns>
33523352
/// <exception cref="ApiException">A server side error occurred.</exception>
3353-
public virtual System.Threading.Tasks.Task<System.Collections.Generic.ICollection<DnsZone>> GetDnsZonesAsync(string instanceId, string providerTypeId, string credentialId)
3353+
public virtual System.Threading.Tasks.Task<DnsZoneQueryResult> GetDnsZonesAsync(string instanceId, string providerTypeId, string credentialId)
33543354
{
33553355
return GetDnsZonesAsync(instanceId, providerTypeId, credentialId, System.Threading.CancellationToken.None);
33563356
}
@@ -3361,7 +3361,7 @@ public virtual async System.Threading.Tasks.Task<ActionResult> RemoveAcmeAccount
33613361
/// </summary>
33623362
/// <returns>OK</returns>
33633363
/// <exception cref="ApiException">A server side error occurred.</exception>
3364-
public virtual async System.Threading.Tasks.Task<System.Collections.Generic.ICollection<DnsZone>> GetDnsZonesAsync(string instanceId, string providerTypeId, string credentialId, System.Threading.CancellationToken cancellationToken)
3364+
public virtual async System.Threading.Tasks.Task<DnsZoneQueryResult> GetDnsZonesAsync(string instanceId, string providerTypeId, string credentialId, System.Threading.CancellationToken cancellationToken)
33653365
{
33663366
if (instanceId == null)
33673367
throw new System.ArgumentNullException("instanceId");
@@ -3416,7 +3416,7 @@ public virtual async System.Threading.Tasks.Task<ActionResult> RemoveAcmeAccount
34163416
var status_ = (int)response_.StatusCode;
34173417
if (status_ == 200)
34183418
{
3419-
var objectResponse_ = await ReadObjectResponseAsync<System.Collections.Generic.ICollection<DnsZone>>(response_, headers_, cancellationToken).ConfigureAwait(false);
3419+
var objectResponse_ = await ReadObjectResponseAsync<DnsZoneQueryResult>(response_, headers_, cancellationToken).ConfigureAwait(false);
34203420
if (objectResponse_.Object == null)
34213421
{
34223422
throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null);
@@ -5371,6 +5371,101 @@ public virtual async System.Threading.Tasks.Task<ActionResult> RemoveStoredCrede
53715371
}
53725372
}
53735373

5374+
/// <summary>
5375+
/// Unlock Stored Credential [Generated]
5376+
/// </summary>
5377+
/// <returns>OK</returns>
5378+
/// <exception cref="ApiException">A server side error occurred.</exception>
5379+
public virtual System.Threading.Tasks.Task<StoredCredentialUnlockResult> UnlockStoredCredentialAsync(string instanceId, string storageKey)
5380+
{
5381+
return UnlockStoredCredentialAsync(instanceId, storageKey, System.Threading.CancellationToken.None);
5382+
}
5383+
5384+
/// <param name="cancellationToken">A cancellation token that can be used by other objects or threads to receive notice of cancellation.</param>
5385+
/// <summary>
5386+
/// Unlock Stored Credential [Generated]
5387+
/// </summary>
5388+
/// <returns>OK</returns>
5389+
/// <exception cref="ApiException">A server side error occurred.</exception>
5390+
public virtual async System.Threading.Tasks.Task<StoredCredentialUnlockResult> UnlockStoredCredentialAsync(string instanceId, string storageKey, System.Threading.CancellationToken cancellationToken)
5391+
{
5392+
if (instanceId == null)
5393+
throw new System.ArgumentNullException("instanceId");
5394+
5395+
if (storageKey == null)
5396+
throw new System.ArgumentNullException("storageKey");
5397+
5398+
var client_ = _httpClient;
5399+
var disposeClient_ = false;
5400+
try
5401+
{
5402+
using (var request_ = new System.Net.Http.HttpRequestMessage())
5403+
{
5404+
request_.Content = new System.Net.Http.StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json");
5405+
request_.Method = new System.Net.Http.HttpMethod("POST");
5406+
request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("text/plain"));
5407+
5408+
var urlBuilder_ = new System.Text.StringBuilder();
5409+
if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl);
5410+
// Operation Path: "internal/v1/credentials/{instanceId}/{storageKey}/unlock"
5411+
urlBuilder_.Append("internal/v1/credentials/");
5412+
urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(instanceId, System.Globalization.CultureInfo.InvariantCulture)));
5413+
urlBuilder_.Append('/');
5414+
urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(storageKey, System.Globalization.CultureInfo.InvariantCulture)));
5415+
urlBuilder_.Append("/unlock");
5416+
5417+
PrepareRequest(client_, request_, urlBuilder_);
5418+
5419+
var url_ = urlBuilder_.ToString();
5420+
request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute);
5421+
5422+
PrepareRequest(client_, request_, url_);
5423+
5424+
var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
5425+
var disposeResponse_ = true;
5426+
try
5427+
{
5428+
var headers_ = new System.Collections.Generic.Dictionary<string, System.Collections.Generic.IEnumerable<string>>();
5429+
foreach (var item_ in response_.Headers)
5430+
headers_[item_.Key] = item_.Value;
5431+
if (response_.Content != null && response_.Content.Headers != null)
5432+
{
5433+
foreach (var item_ in response_.Content.Headers)
5434+
headers_[item_.Key] = item_.Value;
5435+
}
5436+
5437+
ProcessResponse(client_, response_);
5438+
5439+
var status_ = (int)response_.StatusCode;
5440+
if (status_ == 200)
5441+
{
5442+
var objectResponse_ = await ReadObjectResponseAsync<StoredCredentialUnlockResult>(response_, headers_, cancellationToken).ConfigureAwait(false);
5443+
if (objectResponse_.Object == null)
5444+
{
5445+
throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null);
5446+
}
5447+
return objectResponse_.Object;
5448+
}
5449+
else
5450+
{
5451+
var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false);
5452+
throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null);
5453+
}
5454+
}
5455+
finally
5456+
{
5457+
if (disposeResponse_)
5458+
response_.Dispose();
5459+
}
5460+
}
5461+
}
5462+
finally
5463+
{
5464+
if (disposeClient_)
5465+
client_.Dispose();
5466+
}
5467+
}
5468+
53745469
/// <summary>
53755470
/// Get the server software version
53765471
/// </summary>

src/Certify.Server/Certify.Server.Hub.Api/Services/ManagementAPI.cs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System.Text.Json;
1+
using System.Text.Json;
22
using Certify.Client;
33
using Certify.Models;
44
using Certify.Models.Config;
@@ -526,6 +526,25 @@ public async Task<StatusSummary> GetManagedCertificateSummary(AuthContext? curre
526526
return await PerformInstanceCommandTaskWithResult<ActionResult?>(instanceId, args, ManagementHubCommands.RemoveStoredCredential);
527527
}
528528

529+
/// <summary>
530+
/// Unlocks (decrypts and fetches) a stored credential from the target instance.
531+
/// </summary>
532+
/// <param name="instanceId">The target instance identifier.</param>
533+
/// <param name="storageKey">The storage key of the credential to unlock.</param>
534+
/// <param name="authContext">The authentication context.</param>
535+
/// <returns>An <see cref="StoredCredentialUnlockResult"/> decrypted stored credential result.</returns>
536+
public async Task<StoredCredentialUnlockResult?> UnlockStoredCredential(string instanceId, string storageKey, AuthContext authContext)
537+
{
538+
// return populated stored credential via management hub
539+
540+
var args = new KeyValuePair<string, string>[] {
541+
new("instanceId", instanceId) ,
542+
new("storageKey",storageKey)
543+
};
544+
545+
return await PerformInstanceCommandTaskWithResult<StoredCredentialUnlockResult?>(instanceId, args, ManagementHubCommands.UnlockStoredCredential);
546+
}
547+
529548
/// <summary>
530549
/// Retrieves the log entries of a managed certificate from the target instance.
531550
/// </summary>

src/Certify.SourceGenerators/ApiMethods.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System;
1+
using System;
22
using System.Collections.Generic;
33
using System.Linq;
44
using Certify.Models;
@@ -492,6 +492,18 @@ public static List<GeneratedAPI> GetApiDefinitions()
492492
RequiredPermissions = [new(ResourceTypes.StoredCredential, StandardResourceActions.StoredCredentialDelete)]
493493
},
494494
new()
495+
{
496+
OperationName = "UnlockStoredCredential",
497+
OperationMethod = HttpPost,
498+
Comment = "Unlock Stored Credential",
499+
UseManagementAPI = true,
500+
PublicAPIController = "StoredCredential",
501+
PublicAPIRoute = "{instanceId}/{storageKey}/unlock",
502+
ReturnType = GetFormattedTypeName(typeof(Models.Config.StoredCredentialUnlockResult)),
503+
Params = new Dictionary<string, string> { { "instanceId", "string" }, { "storageKey", "string" } },
504+
RequiredPermissions = [new(ResourceTypes.StoredCredential, StandardResourceActions.StoredCredentialReadSecret)]
505+
},
506+
new()
495507
{
496508
OperationName = "GetDeploymentProviders",
497509
OperationMethod = HttpGet,

src/Certify.Tests/Certify.Core.Tests.Unit/Tests/AccessControlTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -866,7 +866,7 @@ public void TestAllStandardResourceActionsAreAllowedByAtLeastOneRole()
866866
.ToList();
867867

868868
// Remove actions that are not applicable to the administrator role
869-
actionsNotAllowedByAdmin.RemoveAll(a => a == StandardResourceActions.StoredCredentialDownload);
869+
actionsNotAllowedByAdmin.RemoveAll(a => a == StandardResourceActions.StoredCredentialReadSecret);
870870
actionsNotAllowedByAdmin.RemoveAll(a => a == StandardResourceActions.ManagementHubInstanceJoin);
871871
actionsNotAllowedByAdmin.RemoveAll(a => a == StandardResourceActions.ManagedChallengeRequest);
872872
actionsNotAllowedByAdmin.RemoveAll(a => a == StandardResourceActions.ManagedChallengeCleanup);

0 commit comments

Comments
 (0)