GroupService.java

/*
 * Copyright (c) 2007-2026 MetaSolutions AB
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.entrystore.rest.springboot.service;

import com.google.common.collect.Sets;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.entrystore.Context;
import org.entrystore.ContextManager;
import org.entrystore.Entry;
import org.entrystore.GraphType;
import org.entrystore.Group;
import org.entrystore.PrincipalManager;
import org.entrystore.impl.RepositoryManagerImpl;
import org.entrystore.rest.springboot.model.exception.BadRequestException;
import org.entrystore.rest.springboot.model.exception.DataConflictException;
import org.entrystore.rest.springboot.model.exception.ForbiddenException;
import org.entrystore.rest.springboot.util.PrincipalManagerUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.net.URI;
import java.util.Locale;
import java.util.regex.Pattern;

@Slf4j
@Service
@RequiredArgsConstructor
public class GroupService {

	// contextId becomes a URI segment, so restrict to URL-safe characters.
	private static final Pattern VALID_CONTEXT_ID = Pattern.compile("^[A-Za-z0-9_-]+$");
	// name is a human-readable label stored as an RDF literal; allow spaces and dots.
	private static final Pattern VALID_NAME = Pattern.compile("^[A-Za-z0-9 ._-]+$");
	private static final int MAX_IDENTIFIER_LENGTH = 64;

	private final RepositoryManagerImpl repositoryManager;
	private final PrincipalManager principalManager;

	private final UserService userService;
	private final ReservedNamesService reservedNamesService;

	@Value("${entrystore.nonadmin.group-context-creation:false}")
	private boolean nonAdminGroupContextCreation;


	/**
	 * Creates a group with linked context.
	 */
	public Entry createGroup(String contextId,
							 String name) {

		ContextManager cm = repositoryManager.getContextManager();

		URI requestingUserUri = principalManager.getAuthenticatedUserURI();

		// guests are prohibited from using this resource
		if (requestingUserUri == null || principalManager.getGuestUser().getURI().equals(requestingUserUri)) {
			throw new ForbiddenException("Not allowed for not-logged in or a guest user to create a group");
		}

		if (!nonAdminGroupContextCreation) {
			if (!userService.isAdmin(principalManager.getUser(requestingUserUri))) {
				throw new ForbiddenException("Not allowed for not-admin user to create a group");
			}
		}

		// normalize inputs to null when blank
		String groupName = StringUtils.trimToNull(name);
		String groupContextId = StringUtils.trimToNull(contextId);

		// validate inputs BEFORE escalating to admin so a rejected request never runs with admin rights
		if (groupName != null) {
			validateIdentifier(groupName, "name", VALID_NAME, "letters, digits, spaces, dots, hyphens, and underscores");
		}
		if (groupContextId != null) {
			validateIdentifier(groupContextId, "contextId", VALID_CONTEXT_ID, "letters, digits, hyphens, and underscores");
		}

		// we need admin-rights to create groups and contexts
		return PrincipalManagerUtil.runAsAdmin(principalManager, () -> {
			// check whether context or group with desired name already exists
			// and abort execution of request if necessary
			if (groupName != null && (principalManager.getPrincipalEntry(groupName) != null || cm.getContextURI(groupName) != null)) {
				throw new DataConflictException("Requested value of the name parameter: '" + groupName + "' is already used");
			}

			if (groupContextId != null && cm.getContext(groupContextId) != null) {
				throw new DataConflictException("Requested value of the contextId parameter: '" + groupContextId + "' is already used");
			}

			// create entry for new group
			Entry newGroupEntry = cm.getContext("_principals").createResource(null, GraphType.Group, null, null);
			// make the requesting user admin for group
			newGroupEntry.setAllowedPrincipalsFor(PrincipalManager.AccessProperty.Administer, Sets.newHashSet(requestingUserUri));
			// change creator from admin to requesting user
			newGroupEntry.setCreator(requestingUserUri);

			Group newGroup = (Group) newGroupEntry.getResource();
			// make requesting user a group member
			newGroup.addMember(principalManager.getUser(requestingUserUri));

			if (groupName != null) {
				// set name of the group
				newGroup.setName(groupName);
			}

			// create entry for new context
			Entry newContextEntry = cm.getContext("_contexts").createResource(groupContextId, GraphType.Context, null, null);
			// make the requesting user admin for context
			newContextEntry.setAllowedPrincipalsFor(PrincipalManager.AccessProperty.Administer, Sets.newHashSet(requestingUserUri));
			// new group gets write access for context
			newContextEntry.setAllowedPrincipalsFor(PrincipalManager.AccessProperty.WriteResource, Sets.newHashSet(newGroupEntry.getResourceURI()));
			// change creator from admin to requesting user
			newContextEntry.setCreator(requestingUserUri);

			Context newContext = (Context) newContextEntry.getResource();

			if (groupName != null) {
				// set name of the new context
				cm.setName(newContextEntry.getEntryURI(), groupName);
			}

			// set the group's home context to the newly created context
			newGroup.setHomeContext(newContext);

			return newGroupEntry;
		});
	}

	private void validateIdentifier(String value, String fieldName, Pattern allowed, String allowedDescription) {
		if (value.length() > MAX_IDENTIFIER_LENGTH) {
			throw new BadRequestException("Parameter '" + fieldName + "' exceeds maximum length of " + MAX_IDENTIFIER_LENGTH + " characters");
		}
		if (!allowed.matcher(value).matches()) {
			throw new BadRequestException("Parameter '" + fieldName + "' must contain only " + allowedDescription);
		}
		if (value.chars().noneMatch(Character::isLetterOrDigit)) {
			throw new BadRequestException("Parameter '" + fieldName + "' must contain at least one letter or digit");
		}
		if (value.startsWith("_")) {
			throw new BadRequestException("Parameter '" + fieldName + "' must not start with an underscore (reserved for system entities)");
		}
		if (reservedNamesService.isReservedName(value.toLowerCase(Locale.ROOT))) {
			throw new BadRequestException("Parameter '" + fieldName + "' is a reserved name");
		}
	}

}