
de.arbeitsagentur.opdt.keycloak.filestore.EntityIO Maven / Gradle / Ivy
/*
* Copyright 2024. IT-Systemhaus der Bundesagentur fuer Arbeit
*
* 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 de.arbeitsagentur.opdt.keycloak.filestore;
import static java.util.Map.entry;
import de.arbeitsagentur.opdt.keycloak.filestore.client.FileClientEntity;
import de.arbeitsagentur.opdt.keycloak.filestore.clientscope.FileClientScopeEntity;
import de.arbeitsagentur.opdt.keycloak.filestore.common.AbstractEntity;
import de.arbeitsagentur.opdt.keycloak.filestore.common.UpdatableEntity;
import de.arbeitsagentur.opdt.keycloak.filestore.group.FileGroupEntity;
import de.arbeitsagentur.opdt.keycloak.filestore.realm.FileRealmEntity;
import de.arbeitsagentur.opdt.keycloak.filestore.role.FileRoleEntity;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.apache.commons.text.StringSubstitutor;
import org.jboss.logging.Logger;
import org.keycloak.Config;
import org.yaml.snakeyaml.DumperOptions;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.TypeDescription;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.Constructor;
import org.yaml.snakeyaml.introspector.BeanAccess;
import org.yaml.snakeyaml.nodes.Tag;
import org.yaml.snakeyaml.representer.Representer;
public class EntityIO {
public static final String ID_COMPONENT_SEPARATOR = ":";
public static final Pattern ID_COMPONENT_SEPARATOR_PATTERN =
Pattern.compile(Pattern.quote(ID_COMPONENT_SEPARATOR) + "+");
private static final Pattern RESERVED_CHARACTERS = Pattern.compile("[%<:>\"/\\\\|?*=]");
private static final String STORAGE_CONTEXT = "mapStorage";
private static final String STORAGE_TYPE = "file";
private static final String ESCAPING_CHARACTER = "=";
public static final String FILE_SUFFIX = ".yaml";
private static final Logger LOG = Logger.getLogger(EntityIO.class);
static final Map, Function extends AbstractEntity, String[]>>
UNIQUE_HUMAN_READABLE_NAME_FIELD =
Map.ofEntries(
entry(
FileRealmEntity.class,
((Function) v -> new String[] {v.getName()})),
entry(
FileClientEntity.class,
((Function) v -> new String[] {v.getClientId()})),
entry(
FileClientScopeEntity.class,
((Function) v -> new String[] {v.getName()})),
entry(
FileGroupEntity.class,
((Function)
v ->
v.getParentId() == null
? new String[] {v.getName()}
: new String[] {v.getParentId(), v.getName()})),
entry(
FileRoleEntity.class,
((Function)
(v ->
v.getClientId() == null
? new String[] {v.getName()}
: new String[] {v.getClientId(), v.getName()}))));
static E yamlParseFile(
Path fileName, Class interfaceOfEntity) {
var loaderoptions = new LoaderOptions();
loaderoptions.setTagInspector(tag -> false);
Constructor constructor =
new Constructor(new TypeDescription(interfaceOfEntity), null, loaderoptions);
DumperOptions options = new DumperOptions();
options.setIndent(4);
options.setIndicatorIndent(2);
options.setIndentWithIndicator(false);
Representer representer = new Representer(options);
representer.getPropertyUtils().setSkipMissingProperties(true);
representer
.getPropertyUtils()
.setBeanAccess(BeanAccess.FIELD); // Avoid circular dependencies when using setters
Yaml yaml = new Yaml(constructor, representer);
try {
String rawYaml = Files.readString(fileName, StandardCharsets.UTF_8);
String substitutedYaml = new StringSubstitutor(System::getenv).replace(rawYaml);
return yaml.load(substitutedYaml);
} catch (Exception e) {
throw new IllegalStateException("Failed to parse file: " + fileName, e);
}
}
static void writeToFile(E entity, Path path)
throws IOException {
var loaderoptions = new LoaderOptions();
loaderoptions.setTagInspector(tag -> false);
Constructor constructor = new Constructor(entity.getClass(), loaderoptions);
DumperOptions options = new DumperOptions();
options.setIndent(4);
options.setIndicatorIndent(2);
options.setIndentWithIndicator(false);
Representer representer = new Representer(options);
representer.getPropertyUtils().setSkipMissingProperties(true);
representer.addClassTag(Set.class, Tag.SEQ);
Yaml yaml = new Yaml(constructor, representer);
String output = yaml.dumpAs(entity, Tag.MAP, DumperOptions.FlowStyle.BLOCK);
if (!Files.exists(path.getParent())) {
Files.createDirectories(path.getParent());
}
Files.write(path, output.getBytes());
}
static E parseFile(
Path fileName, Class interfaceOfEntity) {
final E parsedObject = yamlParseFile(fileName, interfaceOfEntity);
if (parsedObject == null) {
return null;
}
final String fileNameStr = fileName.getFileName().toString();
final String idFromFilename =
fileNameStr.substring(0, fileNameStr.length() - FILE_SUFFIX.length());
String escapedId = determineKeyFromValue(parsedObject, interfaceOfEntity, idFromFilename);
if (escapedId == null) {
LOG.tracef("Determined ID from filename: %s%s", idFromFilename);
escapedId = idFromFilename;
} else if (!escapedId.endsWith(idFromFilename)) {
LOG.warnf(
"Id \"%s\" does not conform with filename \"%s\", expected: %s",
escapedId, fileNameStr, escapeId(escapedId));
}
if (parsedObject.getId() == null) {
parsedObject.setId(escapedId);
}
parsedObject.clearUpdatedFlag();
return parsedObject;
}
private static String determineKeyFromValue(
E value, Class interfaceOfEntity, String lastIdComponentIfUnset) {
String[] proposedId = getSuggestedPath(value, interfaceOfEntity);
if (proposedId == null || proposedId.length == 0) {
return lastIdComponentIfUnset;
} else if (proposedId[proposedId.length - 1] == null) {
proposedId[proposedId.length - 1] = lastIdComponentIfUnset;
}
String[] escapedProposedId = escapeId(proposedId);
final String res = String.join(ID_COMPONENT_SEPARATOR, escapedProposedId);
if (LOG.isTraceEnabled()) {
LOG.tracef(
"determineKeyFromValue: got %s (%s) for %s",
res, res == null ? null : String.join(" [/] ", proposedId), value);
}
return res;
}
private static String[] getSuggestedPath(
E entity, Class clazz) {
Function fileNameByEntity =
((Function) UNIQUE_HUMAN_READABLE_NAME_FIELD.get(clazz));
if (fileNameByEntity == null) {
fileNameByEntity = e -> e.getId() == null ? null : new String[] {e.getId()};
}
return fileNameByEntity.apply(entity);
}
public static String[] escapeId(String[] idArray) {
if (idArray == null || idArray.length == 0 || idArray.length == 1 && idArray[0] == null) {
return null;
}
return Stream.of(idArray).map(EntityIO::escapeId).toArray(String[]::new);
}
public static String escapeId(String id) {
Objects.requireNonNull(id, "ID must be non-null");
StringBuilder idEscaped = new StringBuilder();
Matcher m = RESERVED_CHARACTERS.matcher(id);
while (m.find()) {
m.appendReplacement(
idEscaped, String.format(ESCAPING_CHARACTER + "%02x", (int) m.group().charAt(0)));
}
m.appendTail(idEscaped);
final Path pId = Path.of(idEscaped.toString());
return pId.toString();
}
public static Path getPathForIdAndParentPath(String escapedId, Path parentPath) {
String[] escapedIdArray = ID_COMPONENT_SEPARATOR_PATTERN.split(escapedId);
Path parentDirectory = parentPath;
Path targetPath = parentDirectory;
for (String path : escapedIdArray) {
targetPath = targetPath.resolve(path).normalize();
if (!targetPath.getParent().equals(parentDirectory)) {
LOG.warnf("Path traversal detected: %s", Arrays.toString(escapedIdArray));
return null;
}
parentDirectory = targetPath;
}
return targetPath.resolveSibling(targetPath.getFileName() + FILE_SUFFIX);
}
public static Path getRootDirectory() {
String[] scopes = {STORAGE_CONTEXT, STORAGE_TYPE};
String root = Config.scope(scopes).get("dir");
if (root == null) {
String scopesString = String.join(",", scopes);
throw new IllegalStateException(
"Map Storage file directory not found. This indicates that the environment variable is not set for this scope combination: "
+ scopesString);
}
return Path.of(root);
}
public static boolean canParseFile(Path p) {
if (p == null) {
return false;
}
final String fn = p.getFileName().toString();
try {
return Files.isRegularFile(p)
&& Files.size(p) > 0L
&& !fn.startsWith(".")
&& fn.endsWith(FILE_SUFFIX)
&& Files.isReadable(p);
} catch (IOException ex) {
return false;
}
}
public static void deleteParentDirectoryIfEmpty(Path directory) throws IOException {
Path parentDir = directory.getParent();
while (parentDir != null && isDirectoryEmpty(parentDir)) {
Files.delete(parentDir);
parentDir = parentDir.getParent();
}
}
public static boolean isDirectoryEmpty(Path directory) throws IOException {
boolean isEmpty = false;
if (Files.isDirectory(directory)) {
try (Stream entries = Files.list(directory)) {
isEmpty = entries.findFirst().isEmpty();
}
}
return isEmpty;
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy