Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
dc44c1d
Recut the security ladder: Connected 777 becomes Reviewed
toddmitchell Aug 21, 2026
3f35b87
Rename SecurityGroupType.Connected to Reviewed in tests
toddmitchell Aug 21, 2026
e9187fb
Keep AutoConnected = 555 as a legacy enum member
toddmitchell Aug 21, 2026
c315c74
Evaluate legacy 555 files at the reviewed threshold
toddmitchell Aug 21, 2026
09d9ef3
Let apps declare their default circles at registration
toddmitchell Aug 21, 2026
0d91b72
Enrol connections from per-app circles, and have the system apps decl…
toddmitchell Aug 21, 2026
35fb99f
Test the recut tier, and reset the peer cache when a grant promotes
toddmitchell Aug 22, 2026
c510f2a
Merge branch 'app-registrations-to-table' into app-default-circles
toddmitchell Aug 22, 2026
91c415e
Close the second path the connections-list keys took
toddmitchell Aug 22, 2026
56d885a
Let the review replace confirm outright
toddmitchell Aug 24, 2026
0458de6
Read confirmed-ness from the review stamp, not the Confirmed circle
toddmitchell Aug 24, 2026
afe8373
Return what became of each circle from the review
toddmitchell Aug 25, 2026
8b9b724
Give the chat app the relationship circles
toddmitchell Aug 25, 2026
2570019
Let a reviewed caller introduce, without the key
toddmitchell Aug 25, 2026
e1f9a46
Merge branch 'reviewed-tier-cat2' into app-default-circles
toddmitchell Aug 25, 2026
35bdecc
Merge branch 'introductions-follow-the-review' into app-default-circles
toddmitchell Aug 25, 2026
9546e67
Merge branch 'review-stamp-cat1' into app-default-circles
toddmitchell Aug 25, 2026
3b525ad
Add the Reviewed Connections circle, and join contacts to it on review
toddmitchell Aug 25, 2026
8f97bbd
Join reviewed contacts to the circle directly, and backfill them
toddmitchell Aug 26, 2026
a7ded35
Stop reading the auto and confirmed circles as signals
toddmitchell Aug 26, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,9 @@ public async Task<IActionResult> ConfirmConnection([FromBody] OdinIdRequest requ
/// Send no circles for the "chat only" outcome.
/// </summary>
[HttpPost("review")]
public async Task<IActionResult> ReviewConnection([FromBody] ReviewConnectionRequest request)
public async Task<ReviewConnectionResult> ReviewConnection([FromBody] ReviewConnectionRequest request)
{
await circleNetwork.ReviewConnectionAsync(new OdinId(request.OdinId), request.CircleIds, WebOdinContext);
return Ok();
return await circleNetwork.ReviewConnectionAsync(new OdinId(request.OdinId), request.CircleIds, WebOdinContext);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,11 @@ public async Task<bool> DeleteSessionAsync(IOdinContext odinContext)
circleMembershipService.MapCircleGrantsToExchangeGrantsAsync(icr.OdinId.AsciiDomain,
icr.PeerKeyStore.CircleGrants.Values.ToList(), odinContext);

var permissionKeys = tenantContext.Settings.GetAdditionalPermissionKeysForConnectedIdentities();
// Reviewed-tier question -- see the matching gate in CircleNetworkService.
var permissionKeys = icr.IsReviewed()
? tenantContext.Settings.GetAdditionalPermissionKeysForConnectedIdentities()
: new List<int>();

var anonDrivePermissions = tenantContext.Settings.GetAnonymousDrivePermissionsForConnectedIdentities();

// added to allow reading of images for reposted content
Expand Down Expand Up @@ -354,8 +358,9 @@ private async Task<ClientAccessToken> StoreClientAsync(OdinId odinId, SensitiveB
var cc = new CallerContext(
odinId: client.OdinId,
masterKey: null,
securityLevel: SecurityGroupType.Connected,
securityLevel: CircleNetworkService.GetSecurityLevel(icr),
circleIds: enabledCircles,
isConnected: true,
odinClientContext: new OdinClientContext()
{
ClientIdOrDomain = client.OdinId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Odin.Hosting.Controllers.Base;
using Odin.Hosting.Controllers.OwnerToken.YouAuth;
using Odin.Services.Authentication.Owner;
using Odin.Services.Configuration;
using Odin.Services.Authentication.YouAuth;
using Odin.Services.Authorization.Apps;
using Odin.Services.Util;
Expand All @@ -16,7 +17,10 @@ namespace Odin.Hosting.Controllers.OwnerToken.AppManagement
[Route(OwnerApiPathConstants.AppManagementV1)]
[AuthorizeValidOwnerToken]
[ApiExplorerSettings(GroupName = "owner-v1")]
public class AppRegistrationController(IAppRegistrationService appRegistrationService, IYouAuthUnifiedService youAuthUnifiedService)
public class AppRegistrationController(
IAppRegistrationService appRegistrationService,
TenantConfigService tenantConfigService,
IYouAuthUnifiedService youAuthUnifiedService)
: OdinControllerBase
{
/// <summary>
Expand Down Expand Up @@ -73,6 +77,17 @@ public async Task UpdateAuthorizedCircles([FromBody] UpdateAuthorizedCirclesRequ
await appRegistrationService.UpdateAuthorizedCirclesAsync(request, WebOdinContext);
}

/// <summary>
/// Turns an app's grant-on-connect enrollment on or off. The app declares its default circles at
/// install; this is the owner's half. Affects future connections only.
/// </summary>
[HttpPost("register/connect-enrollment")]
public async Task<NoResultResponse> SetConnectEnrollment([FromBody] SetConnectEnrollmentRequest request)
{
await tenantConfigService.SetAppConnectEnrollmentAsync(request.AppId, request.Enabled, WebOdinContext);
return new NoResultResponse(true);
}

/// <summary>
/// Revokes an app; this include all clients using the app and future client registrations until the revocation is removed
/// </summary>
Expand Down
4 changes: 4 additions & 0 deletions src/apps/Odin.Hosting/TenantServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@
using Odin.Services.Configuration.VersionUpgrade.Version12tov13;
using Odin.Services.Configuration.VersionUpgrade.Version13tov14;
using Odin.Services.Configuration.VersionUpgrade.Version14tov15;
using Odin.Services.Configuration.VersionUpgrade.Version15tov16;
using Odin.Services.Configuration.VersionUpgrade.Version16tov17;
using Odin.Services.Security.Email;
using Odin.Services.Security.Health;
using Odin.Services.Security.PasswordRecovery.RecoveryPhrase;
Expand Down Expand Up @@ -403,6 +405,8 @@ internal static ContainerBuilder ConfigureTenantServices(
cb.RegisterType<V12ToV13VersionMigrationService>().InstancePerLifetimeScope();
cb.RegisterType<V13ToV14VersionMigrationService>().InstancePerLifetimeScope();
cb.RegisterType<V14ToV15VersionMigrationService>().InstancePerLifetimeScope();
cb.RegisterType<V15ToV16VersionMigrationService>().InstancePerLifetimeScope();
cb.RegisterType<V16ToV17VersionMigrationService>().InstancePerLifetimeScope();

cb.RegisterType<VersionUpgradeService>().InstancePerLifetimeScope();
cb.RegisterType<VersionUpgradeScheduler>().InstancePerLifetimeScope();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,9 @@ public async Task<IActionResult> ConfirmConnection([FromBody] OdinIdRequest requ
[HttpPost("review")]
[SwaggerOperation(Tags = [SwaggerInfo.Connections],
Summary = "Complete the connection review: stamp it and enroll the chosen circles")]
public async Task<IActionResult> ReviewConnection([FromBody] ReviewConnectionRequest request)
public async Task<ReviewConnectionResult> ReviewConnection([FromBody] ReviewConnectionRequest request)
{
await circleNetwork.ReviewConnectionAsync(new OdinId(request.OdinId), request.CircleIds, WebOdinContext);
return Ok();
return await circleNetwork.ReviewConnectionAsync(new OdinId(request.OdinId), request.CircleIds, WebOdinContext);
}

[HttpPost("unreview")]
Expand Down
28 changes: 28 additions & 0 deletions src/core/Odin.Core.Storage/Database/Identity/Table/TableCircle.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Threading.Tasks;
using Odin.Core.Identity;
using Odin.Core.Storage.Database.Identity.Connection;
Expand Down Expand Up @@ -33,6 +34,33 @@ internal async Task<int> DeleteAsync(Guid circleId)
return await base.DeleteAsync(odinIdentity, circleId);
}

/// <summary>
/// Circles whose owning app wants members enrolled at the given moment. Hits Idx1Circle
/// (identityId, GrantOn) -- this query is the reason GrantOn is a column and not a blob field.
/// </summary>
internal async Task<List<CircleRecord>> GetByGrantOnAsync(int grantOn)
{
await using var cn = await scopedConnectionFactory.CreateScopedConnectionAsync();
await using var cmd = cn.CreateCommand();

cmd.CommandText =
"SELECT rowId,identityId,circleId,circleName,data,AppId,GrantOn,Designation,Emoji FROM Circle " +
"WHERE identityId = @identityId AND GrantOn = @grantOn;";

cmd.AddParameter("@identityId", DbType.Binary, odinIdentity.IdentityId);
cmd.AddParameter("@grantOn", DbType.Int32, grantOn);

var results = new List<CircleRecord>();

await using var rdr = await cmd.ExecuteReaderAsync(CommandBehavior.Default);
while (await rdr.ReadAsync())
{
results.Add(ReadRecordFromReaderAll(rdr));
}

return results;
}

/// <summary>
/// Every circle for this identity. Circles number in the tens, so this is a single read rather
/// than a paged one -- the paging overload is still there for callers that want it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public class TableCircleCached(TableCircle table, IIdentityTransactionalCacheFac
{
private static readonly List<string> PagingByCircleIdTags = ["PagingByCircleId"];
private const string CacheKeyAll = "GetAll";
private static readonly List<string> ByGrantOnTags = ["ByGrantOn"];

//

Expand Down Expand Up @@ -42,6 +43,7 @@ private Task InvalidateAsync(Guid circleId)
return Cache.InvalidateAsync([
Cache.CreateRemoveByKeyAction(GetCacheKey(circleId)),
Cache.CreateRemoveByKeyAction(CacheKeyAll),
Cache.CreateRemoveByTagsAction(ByGrantOnTags),
Cache.CreateRemoveByTagsAction(PagingByCircleIdTags)
]);
}
Expand Down Expand Up @@ -93,6 +95,18 @@ public async Task<List<CircleRecord>> GetAllAsync(TimeSpan? ttl = null)

//

public async Task<List<CircleRecord>> GetByGrantOnAsync(int grantOn, TimeSpan? ttl = null)
{
return await Cache.GetOrSetListAsync(
"ByGrantOn:" + grantOn,
_ => table.GetByGrantOnAsync(grantOn),
ttl ?? DefaultTtl,
DefaultEntrySize,
ByGrantOnTags);
}

//

public async Task<int> DeleteAsync(Guid circleId)
{
var result = await table.DeleteAsync(circleId);
Expand Down
6 changes: 5 additions & 1 deletion src/core/Odin.Core/Exceptions/OdinClientErrorCode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,13 @@ public enum OdinClientErrorCode
NotAFollowerIdentity = 3007,
IdentityNotFollowed = 3008,
IdentityAlreadyFollowed = 3009,
CannotGrantAutoConnectedMoreCircles = 3010,
// 3010 was CannotGrantAutoConnectedMoreCircles: the lockout that forced a confirm before an
// auto-connection could gain circles. Retired with the review; the number is not reused.
IncomingRequestNotFound = 3011,
CannotUnreviewCircleMember = 3012,
CannotGrantKeysOnAmbientCircle = 3013,
CannotGrantReadOnAmbientCircle = 3014,
CircleNotOwnedByApp = 3015,

// Drive mgmt errors 40xx
CannotAllowAnonymousReadsOnOwnerOnlyDrive = 4001,
Expand Down
160 changes: 154 additions & 6 deletions src/services/Odin.Services/Apps/SystemAppConstants.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using Odin.Services.Authorization.Apps;
using Odin.Services.Authorization.Apps;
using Odin.Services.Authorization.ExchangeGrants;
using Odin.Services.Membership.Circles;
using Odin.Services.Authorization.Permissions;
using Odin.Services.Base;
using Odin.Services.Drives;
Expand All @@ -17,14 +19,115 @@ public static class SystemAppConstants
public static readonly Guid PhotoAppId = Guid.Parse("32f0bdbf-017f-4fc0-8004-2d4631182d1e");
public static readonly Guid MailAppId = Guid.Parse("6e8ecfff-7c15-40e4-94f4-d6e83bfb5857");

// Stable ids for the per-app grant-on-connect circles. These replace the chat suite's slice of the
// frozen system-circle bundle: the same drive grants, but owned by the app that actually wants them
// and computed at connect time rather than compiled into CircleConstants.
//
// Deposit-only by construction -- Write|React and nothing else -- which is what the definition-write
// validator enforces for anything with GrantOn = Connect.
public static readonly Guid ChatConnectCircleId = Guid.Parse("c17a1000-0000-4000-8000-000000000001");
public static readonly Guid MailConnectCircleId = Guid.Parse("c17a1000-0000-4000-8000-000000000002");
public static readonly Guid FeedConnectCircleId = Guid.Parse("c17a1000-0000-4000-8000-000000000003");

/// <summary>
/// The relationship circles the chat app owns: the set the owner console's setup wizard has always
/// seeded on a fresh identity, now provisioned by the server alongside the app that presents them.
/// </summary>
/// <remarks>
/// The ids are <c>md5(name)</c>, which is what the wizard assigns (odin-js <c>toGuidId</c>). Keeping
/// them is what makes an identity that already ran the wizard and one provisioned here the same
/// identity: the v16 -> v17 migration rebinds the wizard's circles at these very ids rather than
/// creating a second set beside them.
/// <para>
/// Created if missing and never overwritten -- deliberately not declared as
/// <see cref="AppRegistrationRequest.DefaultCircles"/>, which an app re-registration reapplies and
/// would use to reset a circle the owner has since renamed or regranted. These are the owner's
/// circles with the owner's people in them; the app owns them only in the sense of managing them.
/// </para>
/// </remarks>
public static readonly IReadOnlyList<CreateCircleRequest> ChatRelationshipCircles =
[
new()
{
Id = Guid.Parse("3d594614f445f6b00014e9b77730b833"),
Name = "Friends",
Description = "Your friends",
AppId = ChatAppId,
Permissions = new PermissionSet(PermissionKeys.ReadConnections)
},
new()
{
Id = Guid.Parse("cefc4f7cbc8c34762e0f76703e7e174e"),
Name = "Family",
Description = "Your family",
AppId = ChatAppId,
Permissions = new PermissionSet(PermissionKeys.ReadConnections)
},
new()
{
Id = Guid.Parse("0f9263536b9fc61ada745644735bfd8f"),
Name = "Work",
Description = "Your professional connections",
AppId = ChatAppId,
Permissions = new PermissionSet(PermissionKeys.ReadConnections)
},
new()
{
Id = Guid.Parse("55c53cfda992192581cb4f006109df47"),
Name = "Acquaintances",
Description = "Your network",
AppId = ChatAppId,
Permissions = new PermissionSet(PermissionKeys.ReadConnections)
}
];

public static readonly AppRegistrationRequest ChatAppRegistrationRequest = new()
{
AppId = ChatAppId,
Name = "Homebase - Chat",
AuthorizedCircles = new List<Guid>() //note: by default the system circle will have write access to chat drive
DefaultCircles =
[
new AppDefaultCircleRequest
{
Id = ChatConnectCircleId,
Name = "Chat-only",
Description = "People who can message you before you have reviewed them",
GrantOn = CircleGrantOn.Connect,
Designation = CircleDesignation.Personal,
DriveGrants =
[
new()
{
PermissionedDrive = new PermissionedDrive()
{
Drive = SystemDriveConstants.ChatDrive,
Permission = DrivePermission.Write | DrivePermission.React
}
},
new()
{
PermissionedDrive = new PermissionedDrive()
{
Drive = SystemDriveConstants.ListsDrive,
Permission = DrivePermission.Write | DrivePermission.React
}
},
new()
{
PermissionedDrive = new PermissionedDrive()
{
Drive = SystemDriveConstants.MomentsDrive,
Permission = DrivePermission.Write | DrivePermission.React
}
}
]
}
],

AuthorizedCircles = new List<Guid>()
{
SystemCircleConstants.ConfirmedConnectionsCircleId,
SystemCircleConstants.AutoConnectionsCircleId
// The app's own grant-on-connect circle, which carries the same drives the system circles did.
ChatConnectCircleId
},
CircleMemberPermissionGrant = new PermissionSetGrantRequest()
{
Expand Down Expand Up @@ -146,6 +249,29 @@ public static class SystemAppConstants
{
AppId = FeedAppId,
Name = "Homebase - Feed",
DefaultCircles =
[
new AppDefaultCircleRequest
{
Id = FeedConnectCircleId,
Name = "Feed",
Description = "People whose posts can reach your feed before you have reviewed them",
GrantOn = CircleGrantOn.Connect,
Designation = CircleDesignation.Personal,
DriveGrants =
[
new()
{
PermissionedDrive = new PermissionedDrive()
{
Drive = SystemDriveConstants.FeedDrive,
Permission = DrivePermission.Write | DrivePermission.React
}
}
]
}
],

AuthorizedCircles = [],
Drives =
[new()
Expand Down Expand Up @@ -219,10 +345,32 @@ public static class SystemAppConstants
{
AppId = MailAppId,
Name = "Homebase - Mail",
AuthorizedCircles = new List<Guid>() //note: by default the system circle will have write access to chat drive
DefaultCircles =
[
new AppDefaultCircleRequest
{
Id = MailConnectCircleId,
Name = "Mail",
Description = "People who can mail you before you have reviewed them",
GrantOn = CircleGrantOn.Connect,
Designation = CircleDesignation.Personal,
DriveGrants =
[
new()
{
PermissionedDrive = new PermissionedDrive()
{
Drive = SystemDriveConstants.MailDrive,
Permission = DrivePermission.Write | DrivePermission.React
}
}
]
}
],

AuthorizedCircles = new List<Guid>()
{
SystemCircleConstants.ConfirmedConnectionsCircleId,
SystemCircleConstants.AutoConnectionsCircleId
MailConnectCircleId
},
CircleMemberPermissionGrant = new PermissionSetGrantRequest()
{
Expand Down
Loading