Skip to content

Commit a69286e

Browse files
committed
Make user.team a { _id, role } object for ACL team-role gating (fixes #650)
1 parent 53ee32c commit a69286e

20 files changed

Lines changed: 313 additions & 58 deletions

accounts/src/main/java/org/restheart/accounts/AccountsInitializer.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,9 @@ public void init() {
151151
users.createIndex(Indexes.ascending("emailVerificationToken"),
152152
new IndexOptions().sparse(true).name("emailVerificationToken_1"));
153153

154-
users.createIndex(Indexes.ascending("team"),
155-
new IndexOptions().name("team_1"));
154+
// Active team is stored as a { _id, role } object (9.6.0+); index the id.
155+
users.createIndex(Indexes.ascending("team._id"),
156+
new IndexOptions().name("team._id_1"));
156157
}
157158

158159
// oauth_codes — TTL: codes expire after 600 seconds

accounts/src/main/java/org/restheart/accounts/ActivateService.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import org.restheart.accounts.util.RequestOverrides;
1010
import org.restheart.accounts.util.Errors;
1111
import org.restheart.accounts.util.JwtHelper;
12+
import org.restheart.plugins.accounts.TeamClaim;
1213
import org.restheart.accounts.util.TokenDelivery;
1314
import org.restheart.accounts.util.TokenUtils;
1415
import org.restheart.exchange.JsonRequest;
@@ -161,7 +162,7 @@ public void handle(JsonRequest req, JsonResponse res) {
161162
userRoles.add(effectiveRole);
162163

163164
var extraClaims = new HashMap<String, Object>();
164-
extraClaims.put(conf.teamClaimName(), teamId);
165+
extraClaims.put(conf.teamClaimName(), TeamClaim.of(teamId, orgRole));
165166

166167
var jwtToken = jwt.issueToken(normalizedEmail, userRoles,
167168
RequestOverrides.db(req, conf),

accounts/src/main/java/org/restheart/accounts/EmailVerificationService.java

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import org.restheart.accounts.util.DbHelper;
1010
import org.restheart.accounts.util.JwtHelper;
1111
import org.restheart.accounts.util.RequestOverrides;
12+
import org.restheart.plugins.accounts.TeamClaim;
1213
import org.restheart.accounts.util.TokenDelivery;
1314
import org.restheart.accounts.util.TokenUtils;
1415
import org.restheart.exchange.JsonRequest;
@@ -162,15 +163,12 @@ public void handle(JsonRequest req, JsonResponse res) throws Exception {
162163
roles.add(effectiveRole);
163164

164165
// ── 5d. Issue JWT ─────────────────────────────────────────────────────
165-
var teamBson = accountsService.getMembershipProvider(req)
166-
.activeMembership(storedEmail)
167-
.map(m -> m.teamId())
168-
.orElse(null);
166+
var activeMembership = accountsService.getMembershipProvider(req)
167+
.activeMembership(storedEmail);
169168

170169
var extraClaims = new java.util.HashMap<String, Object>();
171-
if (teamBson != null) {
172-
extraClaims.put(conf.teamClaimName(), teamBson);
173-
}
170+
activeMembership.ifPresent(m ->
171+
extraClaims.put(conf.teamClaimName(), TeamClaim.of(m.teamId(), m.role())));
174172

175173
var jwtToken = jwt.issueToken(
176174
storedEmail,

accounts/src/main/java/org/restheart/accounts/GetTeamsService.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import org.restheart.accounts.util.Errors;
1212
import org.restheart.accounts.util.JwtHelper;
1313
import org.restheart.accounts.util.RequestOverrides;
14+
import org.restheart.plugins.accounts.TeamClaim;
1415
import org.restheart.accounts.util.TokenDelivery;
1516
import org.restheart.exchange.JsonRequest;
1617
import org.restheart.exchange.JsonResponse;
@@ -181,7 +182,8 @@ private void handleCreate(JsonRequest req, JsonResponse res) {
181182
dbRoles,
182183
RequestOverrides.db(req, conf),
183184
req.attachedParams(),
184-
java.util.Map.<String, Object>of(conf.teamClaimName(), teamRef.id()),
185+
java.util.Map.<String, Object>of(conf.teamClaimName(),
186+
TeamClaim.of(teamRef.id(), RequestOverrides.ownershipRole(req, conf))),
185187
null);
186188

187189
var delivery = TokenDelivery.resolve(

accounts/src/main/java/org/restheart/accounts/ResetPasswordService.java

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import org.restheart.accounts.util.RequestOverrides;
1313
import org.restheart.accounts.util.Errors;
1414
import org.restheart.accounts.util.JwtHelper;
15+
import org.restheart.plugins.accounts.TeamClaim;
1516
import org.restheart.accounts.util.TokenDelivery;
1617
import org.restheart.accounts.util.TokenUtils;
1718
import org.restheart.exchange.JsonRequest;
@@ -162,14 +163,11 @@ public void handle(JsonRequest req, JsonResponse res) throws Exception {
162163
db(req).unsetUserFields(storedEmail, List.of("passwordResetToken", "passwordResetCreatedAt"));
163164

164165
// 7. Auto-login: issue a fresh JWT and set the auth cookie
165-
var team = accountsService.getMembershipProvider(req)
166-
.activeMembership(storedEmail)
167-
.map(m -> m.teamId())
168-
.orElse(null);
166+
var activeMembership = accountsService.getMembershipProvider(req)
167+
.activeMembership(storedEmail);
169168
var extraClaims = new java.util.HashMap<String, Object>();
170-
if (team != null) {
171-
extraClaims.put(conf.teamClaimName(), team);
172-
}
169+
activeMembership.ifPresent(m ->
170+
extraClaims.put(conf.teamClaimName(), TeamClaim.of(m.teamId(), m.role())));
173171
var jwtToken = jwt.issueToken(storedEmail, roles,
174172
RequestOverrides.db(req, conf),
175173
req.attachedParams(),

accounts/src/main/java/org/restheart/accounts/SwitchTeamService.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import org.restheart.accounts.util.JwtHelper;
1111
import org.restheart.accounts.util.DbHelper;
1212
import org.restheart.accounts.util.RequestOverrides;
13+
import org.restheart.plugins.accounts.TeamClaim;
1314
import org.restheart.accounts.util.TokenDelivery;
1415
import org.restheart.exchange.JsonRequest;
1516
import org.restheart.exchange.JsonResponse;
@@ -153,18 +154,19 @@ public void handle(JsonRequest req, JsonResponse res) {
153154
dbRoles,
154155
RequestOverrides.db(req, conf),
155156
req.attachedParams(),
156-
java.util.Map.<String, Object>of(conf.teamClaimName(), matched.teamId()),
157+
java.util.Map.<String, Object>of(conf.teamClaimName(),
158+
TeamClaim.of(matched.teamId(), matched.role())),
157159
null);
158160

159161
// Deliver the reissued token per the `delivery` query parameter
160162
// (cookie by default, body for bearer SPAs).
161163
var delivery = TokenDelivery.resolve(
162164
req.getQueryParameterOrDefault("delivery", null), TokenDelivery.Mode.COOKIE);
163165

164-
// Response body
166+
// Response body — team mirrors the { _id, role } claim shape
165167
var responseBody = new JsonObject();
166-
responseBody.add(conf.teamClaimName(), JsonParser.parseString(BsonUtils.toJson(matched.teamId())));
167-
responseBody.addProperty("role", matched.role());
168+
responseBody.add(conf.teamClaimName(),
169+
JsonParser.parseString(BsonUtils.toJson(TeamClaim.of(matched.teamId(), matched.role()))));
168170
if (delivery == TokenDelivery.Mode.BODY) {
169171
TokenDelivery.body(res, responseBody, conf, token);
170172
} else {

accounts/src/main/java/org/restheart/accounts/oauth/OAuthCallback.java

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import org.restheart.accounts.util.DbHelper;
1313
import org.restheart.accounts.util.RequestOverrides;
1414
import org.restheart.accounts.util.JwtHelper;
15+
import org.restheart.plugins.accounts.TeamClaim;
1516
import org.restheart.accounts.util.TokenDelivery;
1617
import org.restheart.exchange.ExchangeKeys.METHOD;
1718
import org.restheart.exchange.Request;
@@ -209,7 +210,8 @@ public void handle(StringRequest req, StringResponse res) throws Exception {
209210
var jwtToken = jwt.issueToken(email, activatedRoles,
210211
RequestOverrides.db(req, conf),
211212
req.attachedParams(),
212-
java.util.Map.<String, Object>of(conf.teamClaimName(), membership.get().teamId()),
213+
java.util.Map.<String, Object>of(conf.teamClaimName(),
214+
TeamClaim.of(membership.get().teamId(), membership.get().role())),
213215
null);
214216
setAuthCookieAndRedirect(res, req, jwtToken, focr.isNew() ? "signup" : "signin");
215217
return;
@@ -239,11 +241,13 @@ public void handle(StringRequest req, StringResponse res) throws Exception {
239241

240242
var roles = extractRoles(user);
241243
var activeMembership = accountsService.getMembershipProvider(req).activeMembership(email);
242-
var activeTeam = activeMembership.map(m -> m.teamId()).orElse(teamId);
244+
var teamClaim = activeMembership
245+
.map(m -> TeamClaim.of(m.teamId(), m.role()))
246+
.orElseGet(() -> TeamClaim.of(teamId, role));
243247
var jwtToken = jwt.issueToken(email, roles,
244248
RequestOverrides.db(req, conf),
245249
req.attachedParams(),
246-
java.util.Map.<String, Object>of(conf.teamClaimName(), activeTeam),
250+
java.util.Map.<String, Object>of(conf.teamClaimName(), teamClaim),
247251
null);
248252
setAuthCookieAndRedirect(res, req, jwtToken, "signin");
249253
return;
@@ -252,11 +256,13 @@ public void handle(StringRequest req, StringResponse res) throws Exception {
252256
// 4. Issue JWT + set cookie for normal / non-activated users
253257
var roles = extractRoles(user);
254258
var activeMembership = accountsService.getMembershipProvider(req).activeMembership(email);
255-
var activeTeam = activeMembership.map(m -> m.teamId()).orElse(null);
259+
var extraClaims = new java.util.HashMap<String, Object>();
260+
activeMembership.ifPresent(m ->
261+
extraClaims.put(conf.teamClaimName(), TeamClaim.of(m.teamId(), m.role())));
256262
var jwtToken = jwt.issueToken(email, roles,
257263
RequestOverrides.db(req, conf),
258264
req.attachedParams(),
259-
java.util.Map.<String, Object>of(conf.teamClaimName(), activeTeam),
265+
extraClaims,
260266
null);
261267
setAuthCookieAndRedirect(res, req, jwtToken, focr.isNew() ? "signup" : "signin");
262268

accounts/src/main/java/org/restheart/accounts/spi/DefaultMembershipProvider.java

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,9 @@ private TeamRef createTeam(String userId, String teamName, boolean forceActive)
124124
.append("role", new BsonString(ownershipRole)));
125125

126126
if (forceActive) {
127-
db.setActiveTeam(userId, teamIdBson);
127+
db.setActiveTeam(userId, teamIdBson, ownershipRole);
128128
} else {
129-
db.setActiveTeamIfAbsent(userId, teamIdBson);
129+
db.setActiveTeamIfAbsent(userId, teamIdBson, ownershipRole);
130130
}
131131

132132
LOGGER.debug("DefaultMembershipProvider: created team '{}' ({}) for user <{}>",
@@ -143,7 +143,8 @@ public boolean isMember(String userId, BsonValue teamId) {
143143
if (userOpt.isEmpty()) return false;
144144
var user = userOpt.get();
145145

146-
if (user.containsKey("team") && teamId.equals(user.get("team"))) {
146+
var activeId = activeTeamId(user);
147+
if (activeId != null && teamId.equals(activeId)) {
147148
return true;
148149
}
149150
if (user.containsKey("teams") && user.get("teams").isArray()) {
@@ -178,7 +179,7 @@ public void addMember(String userId, BsonValue teamId, String role) {
178179
.append("id", teamId)
179180
.append("role", new BsonString(role)));
180181
db.addMemberToTeam(teamId, userId, role);
181-
db.setActiveTeamIfAbsent(userId, teamId);
182+
db.setActiveTeamIfAbsent(userId, teamId, role);
182183
}
183184

184185
// ── activeMembership ──────────────────────────────────────────────────────
@@ -189,10 +190,10 @@ public Optional<Membership> activeMembership(String userId) {
189190
if (userOpt.isEmpty()) return Optional.empty();
190191
var user = userOpt.get();
191192

192-
if (!user.containsKey("team") || user.get("team").isNull()) {
193+
var teamId = activeTeamId(user);
194+
if (teamId == null) {
193195
return Optional.empty();
194196
}
195-
var teamId = user.get("team");
196197
var role = findRoleInTeams(user, teamId);
197198
var displayName = loadTeamName(teamId);
198199

@@ -207,8 +208,7 @@ public List<Membership> listMemberships(String userId) {
207208
if (userOpt.isEmpty()) return List.of();
208209
var user = userOpt.get();
209210

210-
var activeTeam = user.containsKey("team") && !user.get("team").isNull()
211-
? user.get("team") : null;
211+
var activeTeam = activeTeamId(user);
212212

213213
var result = new ArrayList<Membership>();
214214
if (user.containsKey("teams") && user.get("teams").isArray()) {
@@ -230,11 +230,13 @@ public List<Membership> listMemberships(String userId) {
230230

231231
@Override
232232
public void setActiveMembership(String userId, BsonValue teamId) {
233-
if (!isMember(userId, teamId)) {
233+
var userOpt = db.findUser(userId);
234+
if (userOpt.isEmpty() || !isMember(userId, teamId)) {
234235
throw new IllegalArgumentException(
235236
"User <" + userId + "> is not a member of team " + teamId);
236237
}
237-
db.setActiveTeam(userId, teamId);
238+
var role = findRoleInTeams(userOpt.get(), teamId);
239+
db.setActiveTeam(userId, teamId, role);
238240
}
239241

240242
// ── removeMember ─────────────────────────────────────────────────────
@@ -254,7 +256,7 @@ public void removeMember(String userId, BsonValue teamId) {
254256
db.removeMemberFromTeam(teamId, userId);
255257

256258
// Clear active team if it was this one
257-
if (user.containsKey("team") && teamId.equals(user.get("team"))) {
259+
if (teamId.equals(activeTeamId(user))) {
258260
db.unsetUserFields(userId, List.of("team"));
259261
}
260262

@@ -271,6 +273,9 @@ public void removeMember(String userId, BsonValue teamId) {
271273
public void updateMemberRole(String userId, BsonValue teamId, String newRole) {
272274
db.updateTeamRole(userId, teamId, newRole);
273275
db.updateMemberRoleInTeam(teamId, userId, newRole);
276+
// Keep the denormalized active-team role in sync if this is the user's active team,
277+
// so their next issued/refreshed JWT carries team.role == newRole.
278+
db.setActiveTeamRoleIfActive(userId, teamId, newRole);
274279

275280
LOGGER.info("DefaultMembershipProvider: updated role of <{}> in team {} to '{}'",
276281
userId, teamId, newRole);
@@ -346,7 +351,7 @@ public boolean deleteTeam(String userId, BsonValue teamId) {
346351

347352
db.removeTeamMembership(userId, teamId);
348353
db.findUser(userId)
349-
.filter(u -> u.containsKey("team") && teamId.equals(u.get("team")))
354+
.filter(u -> teamId.equals(activeTeamId(u)))
350355
.ifPresent(u -> db.unsetUserFields(userId, List.of("team")));
351356

352357
LOGGER.info("DefaultMembershipProvider: deleted team {} (requested by <{}>)", teamId, userId);
@@ -386,11 +391,11 @@ public Optional<Membership> activateViaOAuth(String userId, ConsentRecord consen
386391
}
387392

388393
// User must already have an active team (set when the invite was sent)
389-
if (!user.containsKey("team") || user.get("team").isNull()) {
394+
var teamId = activeTeamId(user);
395+
if (teamId == null) {
390396
LOGGER.warn("DefaultMembershipProvider.activateViaOAuth: invited user <{}> has no team", userId);
391397
return Optional.empty();
392398
}
393-
var teamId = user.get("team");
394399

395400
// Activate: assign defaultRole
396401
var rolesArray = new BsonArray();
@@ -409,6 +414,24 @@ public Optional<Membership> activateViaOAuth(String userId, ConsentRecord consen
409414

410415
// ── Helpers ─────────────────────────────────────────────────────
411416

417+
/**
418+
* Extracts the active team's id from the user document, tolerating both the
419+
* current {@code team: { _id, role }} object shape and the legacy scalar
420+
* {@code team: <oid>} shape (pre-9.6.0 data). Returns {@code null} when the
421+
* user has no active team.
422+
*/
423+
private static BsonValue activeTeamId(BsonDocument user) {
424+
if (!user.containsKey("team") || user.get("team").isNull()) {
425+
return null;
426+
}
427+
var team = user.get("team");
428+
if (team.isDocument()) {
429+
var d = team.asDocument();
430+
return d.containsKey("_id") && !d.get("_id").isNull() ? d.get("_id") : null;
431+
}
432+
return team; // legacy scalar id
433+
}
434+
412435
private String findRoleInTeams(BsonDocument user, BsonValue teamId) {
413436
if (user.containsKey("teams") && user.get("teams").isArray()) {
414437
for (var entry : user.getArray("teams")) {

accounts/src/main/java/org/restheart/accounts/util/DbHelper.java

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -201,25 +201,31 @@ public boolean updateTeamRole(String email, BsonValue teamId, String newRole) {
201201
}
202202

203203
/**
204-
* Sets the user's active team (the {@code team} field) unconditionally.
204+
* Sets the user's active team unconditionally. The {@code team} field is stored as
205+
* a {@code { _id, role }} object so the active team's id <em>and</em> the caller's
206+
* role in it travel together into the JWT {@code team} claim (see the {@code team}
207+
* claim shape used for ACL {@code @user.team._id} / {@code @user.team.role}).
205208
*
206209
* @param email the user's email (_id)
207210
* @param teamId the team to make active
211+
* @param role the user's role within that team
208212
* @return {@code true} if a document was matched
209213
*/
210-
public boolean setActiveTeam(String email, BsonValue teamId) {
211-
return updateUser(email, new BsonDocument("team", teamId));
214+
public boolean setActiveTeam(String email, BsonValue teamId, String role) {
215+
return updateUser(email, new BsonDocument("team", activeTeamDoc(teamId, role)));
212216
}
213217

214218
/**
215219
* Sets the user's active team only if the {@code team} field is absent or null.
216220
* Safe to call idempotently after every {@code addTeamMembership} for new users.
221+
* Stored as a {@code { _id, role }} object (see {@link #setActiveTeam}).
217222
*
218223
* @param email the user's email (_id)
219224
* @param teamId the team to set as active
225+
* @param role the user's role within that team
220226
* @return {@code true} if the field was set (document matched and had no prior team)
221227
*/
222-
public boolean setActiveTeamIfAbsent(String email, BsonValue teamId) {
228+
public boolean setActiveTeamIfAbsent(String email, BsonValue teamId, String role) {
223229
var result = users().updateOne(
224230
Filters.and(
225231
eq("_id", new BsonString(email)),
@@ -228,11 +234,41 @@ public boolean setActiveTeamIfAbsent(String email, BsonValue teamId) {
228234
Filters.eq("team", null)
229235
)
230236
),
231-
Updates.set("team", teamId)
237+
Updates.set("team", activeTeamDoc(teamId, role))
232238
);
233239
return result.getModifiedCount() > 0;
234240
}
235241

242+
/**
243+
* Updates the role recorded on the active-team object ({@code team.role}) only when
244+
* the given team is currently the user's active team ({@code team._id} matches).
245+
* Used to keep the denormalized active-team role in sync when a member's role is
246+
* changed while that team is their active one.
247+
*
248+
* @param email the user's email (_id)
249+
* @param teamId the team whose role changed
250+
* @param role the new role
251+
* @return {@code true} if the active-team role was updated (i.e. it was the active team)
252+
*/
253+
public boolean setActiveTeamRoleIfActive(String email, BsonValue teamId, String role) {
254+
var result = users().updateOne(
255+
Filters.and(
256+
eq("_id", new BsonString(email)),
257+
Filters.eq("team._id", teamId)
258+
),
259+
Updates.set("team.role", new BsonString(role))
260+
);
261+
return result.getModifiedCount() > 0;
262+
}
263+
264+
private static BsonDocument activeTeamDoc(BsonValue teamId, String role) {
265+
var doc = new BsonDocument("_id", teamId);
266+
if (role != null) {
267+
doc.append("role", new BsonString(role));
268+
}
269+
return doc;
270+
}
271+
236272
// -------------------------------------------------------------------------
237273
// Teams
238274
// -------------------------------------------------------------------------

0 commit comments

Comments
 (0)